diff --git a/.eslintrc.js b/.eslintrc.js index 545adecb43..23fd8f345b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,8 +5,23 @@ module.exports = { ], parserOptions: { ecmaVersion: 'latest', + ecmaFeatures: { + jsx: true, + }, }, rules: { 'no-console': 'off', }, + overrides: [ + { + files: [ + '**/__tests__/**/*.js', + '**/tests/**/*.js', + 'assets/src/__tests__/*.js', + ], + env: { + jest: true, + }, + }, + ], }; diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 306f87d6ed..cc10ddbdcf 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -55,6 +55,15 @@ jobs: composer-options: "--prefer-dist --with-dependencies" custom-cache-suffix: $(date -u -d "-0 month -$(($(date +%d)-1)) days" "+%F")-codecov-v2 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install npm dependencies + run: npm ci + - name: Install WordPress Test Suite shell: bash run: tests/bin/install-wp-tests.sh wordpress_tests root root 127.0.0.1:3306 latest @@ -123,6 +132,39 @@ jobs: path: phpunit-output.log retention-days: 7 + - name: Run Jest tests with coverage + id: jest_coverage_current + run: | + set +e + npm run test:unit:coverage -- --coverage --coverageReporters=clover --coverageReporters=text > jest-coverage.log 2>&1 + JEST_EXIT=$? + set -e + + echo "=== Jest test output ===" + cat jest-coverage.log + + if [ -f coverage/clover.xml ]; then + # Extract coverage from Jest Clover XML + STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage/clover.xml 2>/dev/null || echo "0") + COVERED=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage/clover.xml 2>/dev/null || echo "0") + + if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED" ]; then + JEST_COVERAGE=$(echo "scale=2; ($COVERED * 100) / $STATEMENTS" | bc) + else + JEST_COVERAGE="0" + fi + + echo "jest_coverage=$JEST_COVERAGE" >> $GITHUB_OUTPUT + echo "Jest coverage: $JEST_COVERAGE%" + + # Save for base branch comparison + cp coverage/clover.xml jest-coverage.xml + else + echo "jest_coverage=0" >> $GITHUB_OUTPUT + echo "No Jest coverage generated" + fi + continue-on-error: true + - name: Generate coverage report summary id: coverage run: | @@ -237,6 +279,33 @@ jobs: head -20 base-coverage-details.txt || true continue-on-error: true + - name: Generate Jest coverage for base branch + if: github.event_name == 'pull_request' + id: base_jest_coverage + run: | + # Install npm dependencies on base branch + npm ci || true + + # Run Jest coverage + npm run test:unit:coverage -- --coverage --coverageReporters=clover > base-jest-coverage.log 2>&1 || true + + if [ -f coverage/clover.xml ]; then + STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage/clover.xml 2>/dev/null || echo "0") + COVERED=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage/clover.xml 2>/dev/null || echo "0") + + if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED" ]; then + BASE_JEST_COVERAGE=$(echo "scale=2; ($COVERED * 100) / $STATEMENTS" | bc) + else + BASE_JEST_COVERAGE="0" + fi + echo "base_jest_coverage=$BASE_JEST_COVERAGE" >> $GITHUB_OUTPUT + echo "Base Jest coverage: $BASE_JEST_COVERAGE%" + else + echo "base_jest_coverage=0" >> $GITHUB_OUTPUT + echo "No base Jest coverage generated" + fi + continue-on-error: true + - name: Generate coverage diff report if: github.event_name == 'pull_request' id: coverage_diff @@ -364,13 +433,14 @@ jobs: fi - name: Comment PR with coverage - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && always() uses: actions/github-script@v7 env: COVERAGE_CHANGES: ${{ steps.coverage_diff.outputs.coverage_changes }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | + // PHP Coverage const current = parseFloat('${{ steps.coverage.outputs.current_coverage }}') || 0; const base = parseFloat('${{ steps.base_coverage.outputs.base_coverage }}') || 0; const diff = (current - base).toFixed(2); @@ -378,6 +448,13 @@ jobs: const coverageEmoji = current >= 80 ? '🎉' : current >= 60 ? '📈' : current >= 40 ? '📊' : '📉'; const status = diff >= -0.5 ? '✅' : '⚠️'; + // Jest Coverage + const jestCurrent = parseFloat('${{ steps.jest_coverage_current.outputs.jest_coverage }}') || 0; + const jestBase = parseFloat('${{ steps.base_jest_coverage.outputs.base_jest_coverage }}') || 0; + const jestDiff = (jestCurrent - jestBase).toFixed(2); + const jestDiffEmoji = jestDiff >= 0 ? '📈' : '📉'; + const jestCoverageEmoji = jestCurrent >= 80 ? '🎉' : jestCurrent >= 60 ? '📈' : jestCurrent >= 40 ? '📊' : '📉'; + // Parse coverage changes JSON from environment variable let changesJson = {}; try { @@ -441,24 +518,39 @@ jobs: const comment = `## ${status} Code Coverage Report + ### PHP Coverage (PHPUnit) + | Metric | Value | |--------|-------| - | **Total Coverage** | **${current.toFixed(2)}%** ${coverageEmoji} | - | Base Coverage | ${base.toFixed(2)}% | - | Difference | ${diffEmoji} **${diff}%** | + | **Current** | **${current.toFixed(2)}%** ${coverageEmoji} | + | Base | ${base.toFixed(2)}% | + | Change | ${diffEmoji} **${diff}%** | - ${current >= 40 ? '✅ Coverage meets minimum threshold (40%)' : '⚠️ Coverage below recommended 40% threshold'} + ${current >= 40 ? '✅ PHP coverage meets threshold (40%)' : '⚠️ PHP coverage below 40%'} - ${diff < -0.5 ? '⚠️ **Warning:** Coverage dropped by more than 0.5%. Please add tests.' : ''} - ${diff >= 0 ? '🎉 Great job maintaining/improving code coverage!' : ''} + ### JavaScript Coverage (Jest) + + | Metric | Value | + |--------|-------| + | **Current** | **${jestCurrent.toFixed(2)}%** ${jestCoverageEmoji} | + | Base | ${jestBase.toFixed(2)}% | + | Change | ${jestDiffEmoji} **${jestDiff}%** | + + ${jestCurrent >= 40 ? '✅ Jest coverage meets threshold (40%)' : '⚠️ Jest coverage below 40%'} + + --- + + ${diff < -0.5 ? '⚠️ **Warning:** PHP coverage dropped by more than 0.5%. Please add tests.' : ''} + ${diff >= 0 ? '🎉 Great job maintaining/improving PHP coverage!' : ''} ${detailedChanges}
ℹ️ About this report - - All tests run in a single job with Xdebug coverage - - Security tests excluded from coverage to prevent output issues + - PHP tests run with Xdebug coverage + - Jest tests run with built-in coverage + - Security tests excluded from PHP coverage - Coverage calculated from line coverage percentages
@@ -507,3 +599,11 @@ jobs: name: coverage-report path: coverage-html/ retention-days: 30 + + - name: Upload Jest coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jest-coverage-report + path: coverage/ + retention-days: 30 diff --git a/.github/workflows/playground-merged.yml b/.github/workflows/playground-merged.yml index 0a129e50ac..5cb421ffcc 100644 --- a/.github/workflows/playground-merged.yml +++ b/.github/workflows/playground-merged.yml @@ -42,5 +42,5 @@ jobs: with: message: | **Test merged PR on Playground** - [Test this pull request on the Playground](https://playground.wordpress.net/#${{ steps.blueprint.outputs.blueprint }}) + [Test this pull request on the Playground](https://playground.progressplanner.com/#${{ steps.blueprint.outputs.blueprint }}) or [download the zip](${{ github.server_url }}/${{ github.repository }}/archive/refs/heads/develop.zip) diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index cdfc5863c9..f2421b61dd 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -109,5 +109,5 @@ jobs: with: message: | **Test on Playground** - [Test this pull request on the Playground](https://playground.wordpress.net/#${{ steps.blueprint.outputs.blueprint }}) + [Test this pull request on the Playground](https://playground.progressplanner.com/#${{ steps.blueprint.outputs.blueprint }}) or [download the zip](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/${{ github.event.repository.name }}.zip) diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml index 3e5014b308..69d854ec1e 100644 --- a/.github/workflows/plugin-check.yml +++ b/.github/workflows/plugin-check.yml @@ -22,8 +22,8 @@ jobs: - name: Build plugin run: | wp dist-archive . ./${{ github.event.repository.name }}.zip - mkdir build - unzip ${{ github.event.repository.name }}.zip -d build + mkdir -p plugin-check-build + unzip ${{ github.event.repository.name }}.zip -d plugin-check-build - name: Run plugin check uses: wordpress/plugin-check-action@v1.1.5 diff --git a/.gitignore b/.gitignore index 47ae5d782f..3103d6566a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ auth.json # Environment variables .env +.claude/ + coverage/ diff --git a/README.md b/README.md index 6b6e782e52..7830551567 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![WordPress Plugin Rating](https://img.shields.io/wordpress/plugin/stars/progress-planner.svg)](https://wordpress.org/support/plugin/progress-planner/reviews/) [![GitHub](https://img.shields.io/github/license/ProgressPlanner/progress-planner.svg)](https://github.com/ProgressPlanner/progress-planner/blob/main/LICENSE) -[![Try Progress Planner on the WordPress playground](https://img.shields.io/badge/Try%20Progress%20Planner%20on%20the%20WordPress%20Playground-%23117AC9.svg?style=for-the-badge&logo=WordPress&logoColor=ddd)](https://playground.wordpress.net/?blueprint-url=https%3A%2F%2Fprogressplanner.com%2Fresearch%2Fblueprint-pp.php%3Frepo%3DProgressPlanner/progress-planner) +[![Try Progress Planner on the WordPress playground](https://img.shields.io/badge/Try%20Progress%20Planner%20on%20the%20WordPress%20Playground-%23117AC9.svg?style=for-the-badge&logo=WordPress&logoColor=ddd)](https://playground.progressplanner.com/?blueprint-url=https%3A%2F%2Fprogressplanner.com%2Fresearch%2Fblueprint-pp.php%3Frepo%3DProgressPlanner/progress-planner) # Progress Planner diff --git a/assets/css/admin.css b/assets/css/admin.css index d0c7981551..6bff60f02a 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -80,24 +80,6 @@ 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. \*------------------------------------*/ @@ -229,39 +211,22 @@ button.prpl-info-icon { height: 88px; } -.prpl-header-right { - display: flex; - gap: var(--prpl-padding); - align-items: center; +.prpl-header-right .prpl-info-icon { - .prpl-info-icon { - width: 2rem; - height: 2rem; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.4em; - - /* color: var(--prpl-color-border); */ - background-color: #fff; - border: 1px solid var(--prpl-color-ui-icon); - border-radius: var(--prpl-border-radius); - - svg { - width: 1rem; - height: 1rem; - - & path { - fill: currentcolor; - } - } + svg { + width: 1rem; + height: 1rem; - &:hover { - color: var(--prpl-color-ui-icon-hover); - border-color: var(--prpl-color-ui-icon-hover); - background-color: var(--prpl-color-ui-icon-hover-fill); + & path { + fill: currentcolor; } } + + &:hover { + color: var(--prpl-color-ui-icon-hover); + border-color: var(--prpl-color-ui-icon-hover); + background-color: var(--prpl-color-ui-icon-hover-fill); + } } /*------------------------------------*\ @@ -553,15 +518,6 @@ button.prpl-info-icon { } } -/*------------------------------------*\ - Layout for columns. -\*------------------------------------*/ -.prpl-columns-wrapper { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--prpl-padding); -} - /*------------------------------------*\ Loader. See https://cssloaders.github.io/ for more. diff --git a/assets/css/dashboard-widgets/score.css b/assets/css/dashboard-widgets/score.css deleted file mode 100644 index e1939c4c8b..0000000000 --- a/assets/css/dashboard-widgets/score.css +++ /dev/null @@ -1,60 +0,0 @@ -/* stylelint-disable max-line-length */ - -/** - * Admin widget. - * - * Dependencies: progress-planner/suggested-task, progress-planner/web-components/prpl-badge - */ -#progress_planner_dashboard_widget_score { - - .prpl-dashboard-widget { - padding-top: 5px; /* Total 16px top spacing */ - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - grid-gap: calc(var(--prpl-gap) / 2); - - > div { - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); - font-size: 0.875rem; - text-align: center; - padding-bottom: 1em; - } - } - - h3 { - font-weight: 500; - } - - .prpl-suggested-task { - - h3 { - margin-bottom: 0; - } - } - - .prpl-dashboard-widget-latest-activities { - margin-top: 1em; - padding-top: 1em; - border-top: 1px solid #c3c4c7; /* same color as the one WP-Core uses */ - li { - display: flex; - justify-content: space-between; - } - } - - .prpl-dashboard-widget-footer { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid #c3c4c7; /* same color as the one WP-Core uses */ - font-size: var(--prpl-font-size-base); - display: flex; - gap: 1rem; - align-items: center; - - .prpl-button-primary { - display: inline-block; - margin: 0.5rem 0 0 0; - } - } -} diff --git a/assets/css/focus-element.css b/assets/css/focus-element.css deleted file mode 100644 index 57ef0cf06d..0000000000 --- a/assets/css/focus-element.css +++ /dev/null @@ -1,41 +0,0 @@ -.prpl-element-awards-points-icon-positioning-wrapper { - position: relative; - display: inline-block; - height: 100%; - - .prpl-element-awards-points-icon-wrapper { - position: absolute; - top: -2em; - left: 0; - } -} - -.prpl-element-awards-points-icon-wrapper { - display: inline-flex; - align-items: center; - gap: 0.25rem; - background-color: #fff9f0; - font-size: 0.75rem; - border: 2px solid #faa310 !important; - border-radius: 1rem; - color: #534786; - font-weight: 600; - padding: 0.25rem; - margin: 0 1rem; - transform: translateY(0.25rem); - scroll-margin-top: 30px; - - img { - width: 0.815rem; - } - - &.focused { - border-color: #14b8a6; - background-color: #f2faf9; - box-shadow: 2px 2px 0 0 #14b8a6; - } - - &.complete { - color: #14b8a6; - } -} diff --git a/assets/css/onboarding/onboarding.css b/assets/css/onboarding/onboarding.css index ae11f3057c..9702254efa 100644 --- a/assets/css/onboarding/onboarding.css +++ b/assets/css/onboarding/onboarding.css @@ -1,3 +1,29 @@ +/* Start onboarding container - shown when privacy not yet accepted */ +.prpl-start-onboarding-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding: 2rem; + text-align: center; +} + +.prpl-start-onboarding-graphic { + max-width: 300px; + margin-bottom: 2rem; +} + +.prpl-start-onboarding-graphic img { + width: 100%; + height: auto; +} + +#prpl-start-onboarding-button { + font-size: 1.25rem; + padding: 1rem 2rem; +} + .prpl-popover-onboarding { --prpl-color-text: #4b5563; @@ -652,6 +678,9 @@ color: var(--prpl-color-link); text-decoration: underline; cursor: pointer; + background: none; + border: none; + padding: 0; &.prpl-quit-link-primary { font-weight: 600; @@ -867,7 +896,10 @@ } .prpl-setting-title { - margin: 0 0 16px 0; + margin: 0 0 1rem 0; + display: flex; + align-items: center; + gap: 5px; } .prpl-settings-progress { @@ -899,7 +931,7 @@ } .prpl-setting-note { - display: none; + display: flex; gap: 10px; border-radius: 12px; padding: 16px; @@ -917,6 +949,10 @@ border-radius: 50%; color: var(--prpl-color-field-border-active); } + + p { + margin: 0; + } } /* Post types sub-step */ diff --git a/assets/css/page-widgets/activity-scores.css b/assets/css/page-widgets/activity-scores.css deleted file mode 100644 index 9c04c9235b..0000000000 --- a/assets/css/page-widgets/activity-scores.css +++ /dev/null @@ -1,6 +0,0 @@ -.prpl-widget-wrapper.prpl-activity-scores { - - .prpl-graph-wrapper { - max-height: 300px; - } -} diff --git a/assets/css/page-widgets/badge-streak.css b/assets/css/page-widgets/badge-streak.css index 3fa64c4562..88546fbe82 100644 --- a/assets/css/page-widgets/badge-streak.css +++ b/assets/css/page-widgets/badge-streak.css @@ -1,5 +1,16 @@ - - +/** + * Badge Streak Widget CSS. + * + * MIGRATION STATUS: Partial + * - React inline: .progress-wrapper styles (StreakBadges/index.js) + * - Must keep: PHP popover styles, widget wrapper layout + * + * These styles cannot be migrated to React because: + * - PHP-rendered popovers require CSS styling + * - Widget wrapper layout used by PHP fallback + */ + +/* PHP-rendered popover styles */ #popover-badge-streak-content, #popover-badge-streak-maintenance { display: grid; @@ -35,10 +46,6 @@ } } -/*------------------------------------*\ - Badges popover. -\*------------------------------------*/ - #prpl-popover-badge-streak { .indicators { @@ -77,6 +84,7 @@ max-width: 42em; } +/* Widget wrapper layout - keep for PHP wrapper */ .prpl-widget-wrapper.prpl-badge-streak, .prpl-widget-wrapper.prpl-badge-streak-content, .prpl-widget-wrapper.prpl-badge-streak-maintenance { @@ -88,31 +96,6 @@ display: inline-block; } - .progress-wrapper { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: calc(var(--prpl-gap) / 2); - - &:not(:first-child) { - margin-top: var(--prpl-padding); - } - - .prpl-badge { - display: flex; - flex-direction: column; - align-items: center; - flex-wrap: wrap; - min-width: 0; - } - - p { - margin: 0; - font-size: var(--prpl-font-size-small); - text-align: center; - line-height: 1.2; - } - } - .prpl-widget-content { margin-bottom: 1em; } diff --git a/assets/css/page-widgets/challenge.css b/assets/css/page-widgets/challenge.css deleted file mode 100644 index 5807a744d5..0000000000 --- a/assets/css/page-widgets/challenge.css +++ /dev/null @@ -1,40 +0,0 @@ -.prpl-widget-wrapper.prpl-challenge { - - &:has(.prpl-challenge-promo-notice) { - position: relative; - - &::after { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--prpl-color-border); - opacity: 0.4; - } - - .prpl-challenge-content { - - .prpl-challenge-promo-notice { - position: absolute; - bottom: var(--prpl-padding); - left: var(--prpl-padding); - z-index: 1; - width: calc(100% - (var(--prpl-padding) * 4)); - background-color: #fff; - border: 1px solid var(--prpl-color-border); - padding: var(--prpl-padding); - border-radius: var(--prpl-border-radius); - - .prpl-button-primary { - margin-bottom: 0; - } - - *:last-child { - margin-bottom: 0; - } - } - } - } -} diff --git a/assets/css/page-widgets/content-activity.css b/assets/css/page-widgets/content-activity.css deleted file mode 100644 index 4548124649..0000000000 --- a/assets/css/page-widgets/content-activity.css +++ /dev/null @@ -1,57 +0,0 @@ -.prpl-widget-wrapper.prpl-content-activity { - - table { - width: 100%; - margin-bottom: 1em; - border-spacing: 6px 0; - } - - th, - td { - border: none; - padding: 0.5em; - - &:not(:first-child) { - text-align: center; - } - } - - th { - text-align: start; - } - - tbody { - - th { - font-weight: 400; - } - - tr { - - &:nth-child(odd) { - background-color: var(--prpl-background-table); - } - } - } - - thead { - - th, - td { - text-align: start; - } - } - - tfoot { - - th, - td { - text-align: start; - border-top: 1px solid var(--prpl-color-border); - } - } - - tr:last-child td { - border-bottom: none; - } -} diff --git a/assets/css/page-widgets/monthly-badges.css b/assets/css/page-widgets/monthly-badges.css index 16a14b8c80..27e4b096db 100644 --- a/assets/css/page-widgets/monthly-badges.css +++ b/assets/css/page-widgets/monthly-badges.css @@ -1,9 +1,21 @@ /** - * Suggested tasks widget. + * Monthly Badges Widget CSS. * - * Dependencies: progress-planner/web-components/prpl-badge + * MIGRATION STATUS: Partial + * - React inline: PointsCounter styles (MonthlyBadges/PointsCounter.js) + * - React inline: Gauge/Badge/BadgeProgressBar components + * - Must keep: PHP popover styles, grid layout media queries, PHP widget wrappers + * + * These styles cannot be migrated to React because: + * - PHP-rendered popovers (#prpl-popover-monthly-badges) require CSS styling + * - Media queries for grid positioning need CSS + * - PHP widget wrapper layout used by fallback rendering + * - CSS :has() selectors for conditional styling + * + * Badge layout styles were migrated from prpl-badge.css web component */ +/* Grid positioning - keep for layout */ @media all and (min-width: 1400px) { .prpl-widget-wrapper.prpl-monthly-badges { @@ -12,6 +24,7 @@ } } +/* PHP-rendered widget wrapper styles */ .prpl-widget-wrapper.prpl-monthly-badges { /* Remove styling from the widget wrapper (but not in popover view). */ @@ -47,21 +60,10 @@ margin-bottom: 0; } } - - .prpl-widget-content-points { - display: flex; - justify-content: space-between; - align-items: center; - - .prpl-widget-content-points-number { - font-size: var(--prpl-font-size-3xl); - font-weight: 600; - } - } } /*------------------------------------*\ - Popover styles. + Popover styles - PHP rendered. \*------------------------------------*/ #prpl-popover-monthly-badges { @@ -105,7 +107,7 @@ } } -/* This is the badge streak widget. */ +/* PHP-rendered badge streak widget in monthly-badges context */ .prpl-widget-wrapper.prpl-badge-streak { display: flex; flex-direction: column; @@ -166,3 +168,47 @@ text-decoration: underline; margin-top: 1.25rem; } + +/*------------------------------------*\ + Badge layout (migrated from prpl-badge.css). +\*------------------------------------*/ +.prpl-badge { + display: grid; + grid-template-columns: 1fr; + min-width: 0; + gap: 0.5rem; + + > * { + align-self: center; + } +} + +.prpl-previous-month-badge-progress-bars-wrapper { + + h3 { + margin-bottom: 6px; + } + + .prpl-previous-month-badge-progress-bars-wrapper-description { + margin-top: 0; + margin-bottom: 1.25rem; + } + + .prpl-previous-month-badge-progress-bar-wrapper:last-of-type { + padding-bottom: 0 !important; /* override inline style */ + } + + /* Single progress bar wrapper */ + .prpl-previous-month-badge-progress-bar-wrapper { + + .prpl-widget-content-points { + justify-content: flex-start !important; + gap: 1rem; + } + + .prpl-widget-previous-ravi-points-number { + font-size: var(--prpl-font-size-3xl); + font-weight: 600; + } + } +} diff --git a/assets/css/page-widgets/suggested-tasks.css b/assets/css/page-widgets/suggested-tasks.css index 8fa3f87a79..4f8512eede 100644 --- a/assets/css/page-widgets/suggested-tasks.css +++ b/assets/css/page-widgets/suggested-tasks.css @@ -1,17 +1,30 @@ /* stylelint-disable max-line-length */ /** - * Suggested tasks widget. + * Suggested Tasks Widget CSS. * - * Dependencies: progress-planner/suggested-task, progress-planner/web-components/prpl-badge + * MIGRATION STATUS: Partial + * - React inline: list/loading/empty styles (SuggestedTasks/index.js) + * - React inline: TaskItem component styles + * - Must keep: PHP popover forms, CSS :has() features, interactive task popovers + * + * These styles cannot be migrated to React because: + * - CSS :has() selectors for conditional display (.prpl-no-suggested-tasks, .prpl-show-all-tasks) + * - PHP-rendered interactive task popovers with forms (inputs, checkboxes, radios) + * - :hover/:disabled pseudo-classes for buttons + * - Complex popover layout with flex columns and dividers + * + * Dependencies: (none) */ +/* Dashboard widget wrapper - PHP context */ .prpl-dashboard-widget-suggested-tasks { .prpl-suggested-tasks-widget-description { max-width: 40rem; } + /* CSS-only :has() features for conditional display */ &:not(:has(.prpl-suggested-tasks-loading)):not(:has(.prpl-suggested-tasks-list li)) { .prpl-no-suggested-tasks { @@ -29,16 +42,10 @@ .prpl-show-all-tasks { display: none; + /* Base styles migrated to React inline in SuggestedTasks/index.js */ .prpl-toggle-all-recommendations-button { - background: none; - border: none; - padding: 0; - color: var(--prpl-color-link); - text-decoration: underline; - cursor: pointer; - font-size: inherit; - font-family: inherit; + /* Hover and disabled states - cannot be done inline in React */ &:hover { color: var(--prpl-color-link-hover); } @@ -61,11 +68,12 @@ display: none; } + /* Base styles (background-color, padding) migrated to React inline in SuggestedTasks/index.js */ + + /* Display logic kept for CSS-only :has() features and PHP-rendered fallbacks */ .prpl-no-suggested-tasks, .prpl-suggested-tasks-loading { display: none; - background-color: var(--prpl-background-activity); - padding: calc(var(--prpl-padding) / 2); } .prpl-suggested-tasks-loading { @@ -73,10 +81,10 @@ } } +/* Last item border removal - CSS-only feature */ + +/* Base list styles (list-style, padding, margin) migrated to React inline in SuggestedTasks/index.js */ .prpl-suggested-tasks-list { - list-style: none; - padding: 0; - margin: 0 0 var(--prpl-padding) 0; &:not(:has(+ .prpl-suggested-tasks-list)) .prpl-suggested-task:last-child { border-bottom: none; @@ -84,12 +92,10 @@ } /*------------------------------------*\ - Interactive tasks, popover. + Interactive tasks, popover - PHP rendered. \*------------------------------------*/ .prpl-popover.prpl-popover-interactive { padding: 24px 24px 14px 24px; - - /* 14px is needed for the "next" button hover state. */ box-sizing: border-box; * { @@ -103,8 +109,6 @@ overflow: hidden; padding-bottom: 10px; - /* Needed for the "next" button hover state. */ - >* { flex-grow: 1; flex-basis: 300px; @@ -136,7 +140,6 @@ .prpl-column { - /* Set margin for headings and paragraphs. */ h1, h2, h3, @@ -170,7 +173,6 @@ } } - /* Set padding and background color for content column (description text). */ &.prpl-column-content { padding: 20px; border-radius: var(--prpl-border-radius-big); @@ -207,8 +209,7 @@ color: var(--prpl-color-alert-error-text); background-color: var(--prpl-background-alert-error); margin-bottom: 0; - - order: 98; /* One less than the spinner. */ + order: 98; flex-grow: 1; .prpl-note-icon { @@ -218,14 +219,12 @@ } } - /* To align the buttons to the bottom of the column. */ &:not(.prpl-column-content) { display: flex; flex-direction: column; - padding-top: 3px; /* To prevent custom radio and checkbox from being cut off. */ + padding-top: 3px; } - /* Inputs. */ input[type="text"], input[type="email"], input[type="number"], @@ -234,12 +233,8 @@ input[type="search"] { height: 44px; padding: 1rem; - - /* WIP */ width: 100%; min-width: 300px; - - /* WIP */ border-radius: 6px; border: 1px solid var(--prpl-color-border); } @@ -254,8 +249,6 @@ border-radius: var(--prpl-border-radius); background-color: var(--prpl-background-banner); cursor: pointer; - - /* WIP: pick exact color */ transition: all 0.25s ease-in-out; position: relative; @@ -265,8 +258,6 @@ width: 100%; height: 100%; background: var(--prpl-background-banner) !important; - - /* WIP: pick exact color */ position: absolute; top: 0; left: 0; @@ -279,12 +270,8 @@ &:focus { background: var(--prpl-background-banner); - /* WIP: pick exact color */ - &::after { background: var(--prpl-background-banner); - - /* WIP: pick exact color */ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.15); width: calc(100% + 4px); height: calc(100% + 4px); @@ -301,11 +288,8 @@ border: 1px solid var(--prpl-color-border); } - /* Used for radio and checkbox inputs. */ .radios { padding-left: 3px; - - /* To prevent custom radio and checkbox from being cut off. */ display: flex; flex-direction: column; gap: 0.5rem; @@ -318,7 +302,6 @@ --prpl-input-green: #3bb3a6; --prpl-input-gray: #8b99a6; - /* 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; @@ -327,7 +310,6 @@ height: 0; } - /* Shared styles for the custom control */ .prpl-custom-control { display: inline-block; vertical-align: middle; @@ -339,7 +321,6 @@ transition: border-color 0.2s, background 0.2s; } - /* Label text styling */ .prpl-custom-checkbox, .prpl-custom-radio { display: flex; @@ -349,7 +330,6 @@ user-select: none; } - /* Checkbox styles */ .prpl-custom-checkbox { .prpl-custom-control { @@ -360,12 +340,10 @@ input[type="checkbox"] { - /* Checkbox hover (off) */ &:hover + .prpl-custom-control { box-shadow: 0 0 0 2px #f7f8fa, 0 0 0 3px var(--prpl-input-green); } - /* Checkbox checked (on) */ &:checked + .prpl-custom-control { background: var(--prpl-input-green); border-color: var(--prpl-input-green); @@ -373,7 +351,6 @@ } } - /* Checkmark */ .prpl-custom-control::after { content: ""; position: absolute; @@ -394,7 +371,6 @@ } } - /* Radio styles */ .prpl-custom-radio { .prpl-custom-control { @@ -403,14 +379,12 @@ background: #fff; } - /* Radio hover (off) */ input[type="radio"] { &:hover + .prpl-custom-control { box-shadow: 0 0 0 2px #f7f8fa, 0 0 0 3px var(--prpl-input-green); } - /* Radio checked (on) */ &:checked + .prpl-custom-control { background: var(--prpl-input-green); border-color: var(--prpl-input-green); @@ -418,7 +392,6 @@ } } - /* Radio dot */ .prpl-custom-control::after { content: ""; position: absolute; @@ -439,7 +412,6 @@ } } - /* Used for next step button. */ .prpl-steps-nav-wrapper { margin-top: auto; padding-top: 1rem; @@ -450,7 +422,6 @@ align-self: flex-end; width: 100%; - /* If there are no other elements in the form, align the button to the left. */ &:only-child { padding-top: 0; } @@ -459,7 +430,6 @@ &.prpl-steps-nav-wrapper-align-left { justify-content: flex-start; - /* Display the spinner after the button. */ .prpl-spinner { order: 99; } @@ -469,14 +439,12 @@ cursor: pointer; margin: 0; - /* If the button has empty data-action attribute disable it. */ &[data-action=""] { pointer-events: none; opacity: 0.5; } } - /* Display the spinner before the button. */ .prpl-spinner { order: -1; } @@ -484,7 +452,6 @@ } } - /* Set the date format. */ &#prpl-popover-set-date-format { .prpl-radio-wrapper { diff --git a/assets/css/page-widgets/todo.css b/assets/css/page-widgets/todo.css index cdd6898d96..69c3626f4b 100644 --- a/assets/css/page-widgets/todo.css +++ b/assets/css/page-widgets/todo.css @@ -1,9 +1,22 @@ /** - * TODOs widget. + * TODO Widget CSS. * - * Dependencies: progress-planner/suggested-task + * MIGRATION STATUS: Partial + * - React inline: list/form styles (TodoWidget/index.js) + * - React inline: TaskItem component styles + * - Must keep: CSS :has() features, delete-all popover, dashboard widget overrides + * + * These styles cannot be migrated to React because: + * - CSS :has() selectors for golden/silver task highlighting + * - CSS :has() for completed tasks visibility and delete-all button + * - PHP-rendered delete-all popover with confirmation dialog + * - :hover pseudo-classes for completed task actions + * - Dashboard widget specific overrides (#progress_planner_dashboard_widget_todo) + * + * Dependencies: (none) */ +/* Widget wrapper padding - PHP context */ .prpl-widget-wrapper.prpl-todo { padding-left: 0; @@ -16,7 +29,7 @@ display: none; } - /* Silver task */ + /* Silver task - CSS-only :has() feature */ &:not(:has(#todo-list li[data-task-points="1"])) { .prpl-todo-silver-task-description { @@ -47,7 +60,7 @@ } } - /* Golden task */ + /* Golden task - CSS-only :has() feature */ &:has(#todo-list li[data-task-points="1"]) { .prpl-todo-silver-task-description { @@ -68,56 +81,18 @@ } } -#create-todo-item { - display: flex; - align-items: center; - flex-direction: row-reverse; - gap: 1em; - - button { - border: 1.5px solid; - border-radius: 50%; - background: none; - box-shadow: none; - display: flex; - align-items: center; - justify-content: center; - padding: 0.2em; - margin-inline-start: 0.3rem; - color: var(--prpl-color-ui-icon); - - .dashicons { - font-size: 0.825em; - width: 1em; - height: 1em; - } - } -} - -#new-todo-content { - flex: 1; - min-width: 0; -} - +/* Hide first/last move buttons - CSS-only feature */ #todo-list, #todo-list-completed { - list-style: none; - padding: 0; - - /* max-height: 30em; */ - /* overflow-y: auto; */ - - /* margin: 0 0 0.5em calc(var(--prpl-padding) * -1); */ - - > *:first-child .move-up, - > *:last-child .move-down { + > *:first-child .prpl-move-up, + > *:last-child .prpl-move-down { visibility: hidden; } } /*------------------------------------*\ - Progress Planner TODO Dashboard widget styles. + Dashboard widget styles - PHP context \*------------------------------------*/ #progress_planner_dashboard_widget_todo { @@ -185,14 +160,11 @@ } } +/* Completed tasks list specific styles */ #todo-list-completed { .prpl-suggested-task { - h3 { - text-decoration: line-through; - } - .prpl-suggested-task-actions-wrapper, .prpl-move-buttons-wrapper, button[data-action="complete"] { @@ -201,74 +173,14 @@ } } +/* Completed details section - CSS-only features */ #todo-list-completed-details { - margin-top: 1rem; - border: 1px solid var(--prpl-color-border); - border-radius: 0.5rem; - - summary { - padding: 0.5rem; - font-weight: 500; - display: flex; - - & > .prpl-todo-list-completed-summary-icon { - margin-inline-start: auto; - display: block; - width: 20px; - height: 20px; - - transition: transform 0.3s ease-in-out; - - svg { - stroke: var(--prpl-color-ui-icon); - } - } - } - - &[open] { - - summary > .prpl-todo-list-completed-summary-icon { - transform: rotate(180deg); - } - } &:not(:has(.prpl-suggested-task)) { display: none; } - #todo-list-completed-delete-all-wrapper { - margin: 0.25rem 0.5rem 0.75rem 0.5rem; - border-top: 1px solid var(--prpl-color-border); - display: none; - - #todo-list-completed-delete-all { - display: flex; - align-items: center; - gap: 0.5rem; - background-color: transparent; - border: none; - padding: 0; - margin: 0.5rem 0 0 0; - cursor: pointer; - color: var(--prpl-color-link); - font-size: var(--prpl-font-size-small); - - svg path { - fill: var(--prpl-color-ui-icon); - } - - &:hover { - text-decoration: underline; - - svg path { - fill: var(--prpl-color-ui-icon-hover-delete); - } - } - - } - } - - /* Show the delete all button if there are at least 3 completed tasks */ + /* Show delete all button when 3+ completed tasks */ &:has(.prpl-suggested-task:nth-of-type(3)) #todo-list-completed-delete-all-wrapper { display: block; } @@ -309,6 +221,7 @@ } } + /* Hover effects for completed tasks */ .prpl-suggested-task:hover { .prpl-suggested-task-points { @@ -323,6 +236,7 @@ } } +/* Loading state overlay */ #todo-list { &:has(.prpl-loader) { @@ -342,7 +256,7 @@ } } - +/* Delete all popover - keep as CSS for complex layout */ #todo-list-completed-delete-all-popover { max-width: 600px; diff --git a/assets/css/page-widgets/whats-new.css b/assets/css/page-widgets/whats-new.css deleted file mode 100644 index 1a71376098..0000000000 --- a/assets/css/page-widgets/whats-new.css +++ /dev/null @@ -1,71 +0,0 @@ -.prpl-widget-wrapper.prpl-whats-new { - - ul { - margin: 0; - - p { - margin: 0; - } - } - - li { - - h3 { - margin-top: 0; - font-size: var(--prpl-font-size-lg); - line-height: 1.25; - font-weight: 600; - margin-bottom: 6px; - - > a { - color: var(--prpl-color-headings); - text-decoration: none; - - .prpl-external-link-icon { - margin-inline-start: 0.15em; - } - - &:hover { - color: var(--prpl-color-link); - text-decoration: underline; - } - } - } - - img { - width: 100%; - } - } - - .prpl-widget-footer { - display: flex; - justify-content: flex-end; - - a { - color: var(--prpl-color-link); - text-decoration: underline; - - &:hover { - color: var(--prpl-color-link-hover); - text-decoration: none; - } - } - } -} - -.prpl-blog-post-image { - width: 100%; - min-height: 120px; - aspect-ratio: 3 / 2; - background-size: cover; - margin-bottom: 0.75rem; - border-radius: var(--prpl-border-radius-big); - border: 1px solid var(--prpl-color-border); - background-color: var(--prpl-color-gauge-remain); /* Fallback, if remote host image is not accessible */ - transition: transform 0.2s, box-shadow 0.2s; - - &:hover { - transform: scale(1.01); - box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2); - } -} diff --git a/assets/css/suggested-task.css b/assets/css/suggested-task.css deleted file mode 100644 index e1b28ee8e5..0000000000 --- a/assets/css/suggested-task.css +++ /dev/null @@ -1,348 +0,0 @@ -.prpl-suggested-task { - margin: 0; - padding: 0.75rem 0.5rem 0.625rem 0.5rem; - display: grid; - grid-template-columns: 1.5rem 1fr 3.5rem; - gap: 0.25rem 0.5rem; - position: relative; - line-height: 1; - - &:nth-child(odd) { - background-color: var(--prpl-background-table); - } - - .prpl-suggested-task-title-wrapper { - display: flex; - align-items: center; - gap: 0.5rem; - justify-content: space-between; - - .prpl-task-title { - width: 100%; - color: var(--prpl-color-text); - } - } - - .prpl-suggested-task-actions-wrapper { - grid-column: 2 / span 1; - display: flex; - } - - .prpl-suggested-task-checkbox { - flex-shrink: 0; /* Prevent shrinking on mobile */ - } - - /* If task has disabled checkbox it's title should be italic. */ - &:has(.prpl-suggested-task-disabled-checkbox-tooltip) { - - h3 { - font-style: italic; - } - } - - h3 { - font-size: 1rem; - margin: 0; - font-weight: 500; - - span { - text-decoration: none; - background-image: linear-gradient(#000, #000); - background-repeat: no-repeat; - background-position: center left; - background-size: 0% 1px; - transition: background-size 500ms ease-in-out; - - /* Give the span a width so the user can edit the task title */ - &:empty { - display: inline-block; - width: 100%; - } - } - } - - input[type="checkbox"][disabled] { - opacity: 0.5; - border-color: #0773bf; - background-color: #effbfe; - } - - &.prpl-suggested-task-celebrated h3 span { - background-size: 100% 1px; - color: inherit; - - /* Accessibility */ - text-decoration: line-through; - text-decoration-color: transparent; - } - - .prpl-suggested-task-points-wrapper { - display: flex; - gap: 0.5rem; - align-items: center; - justify-content: flex-end; - grid-row-end: span 2; - } - - .prpl-suggested-task-points { - font-size: var(--prpl-font-size-xs); - font-weight: 700; - color: var(--prpl-text-point); - background-color: var(--prpl-background-point); - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - } - - .tooltip-actions { - visibility: hidden; - padding-top: 2px; - gap: 0.4rem; - align-items: baseline; - - /* Style for "hover" links. */ - .tooltip-action { - display: inline-flex; - position: relative; - text-decoration: none; - - &:not(:last-child) { - padding-right: 0.4rem; /* same as gap */ - - &::after { - position: absolute; - top: 50%; - transform: translateY(-50%); - right: 0; - - content: ""; - display: inline-block; - width: 1px; - background: var(--prpl-color-text); - height: 0.75rem; - } - } - - .prpl-tooltip-action-text { - line-height: 1; - font-size: var(--prpl-font-size-small); - color: var(--prpl-color-link); - } - - button, - a { - text-decoration: none; - padding: 0; - line-height: 1; - - &:hover, - &:focus, - &:active { - text-decoration: underline; - } - } - - /* Close and toggle radio group buttons should not have a text decoration. */ - .prpl-tooltip-close, - .prpl-toggle-radio-group { - - &:hover, - &:focus, - &:active { - text-decoration: none; - } - } - - } - } - - &:hover, - &:focus-within { - - .tooltip-actions { - visibility: visible; - } - } - - .tooltip-actions:has([data-tooltip-visible]) { - visibility: visible; - } - - .prpl-suggested-task-description { - font-size: 0.825rem; - color: var(--prpl-color-text); - margin: 0; - } - - button { - padding: 0.1rem; - line-height: 0; - margin: 0; - background: none; - border: none; - cursor: pointer; - } - - .icon { - width: 1rem; - height: 1rem; - display: inline-block; - } - - .trash, - .move-up, - .move-down { - padding: 0; - border: 0; - background: none; - color: var(--prpl-color-ui-icon); - cursor: pointer; - box-shadow: none; - margin-top: 1px; - } - - .prpl-move-buttons, - .prpl-suggested-task-checkbox-wrapper, - .prpl-suggested-task-checkbox-wrapper label { - display: flex; - width: 100%; - gap: 0; - flex-direction: column; - align-items: center; - justify-content: center; - } - - .prpl-move-buttons-wrapper { - position: absolute; - left: calc(-8px - 0.5rem); /* -7px is the half width of the arrow, -0.5rem is the padding of the widget */ - top: 50%; - transform: translateY(-50%); - padding: 10px 10px 10px 0; /* Padding is needed for arrows to be accessible on hover */ - } - - .move-up, - .move-down { - height: 0.75rem; - - .dashicons { - font-size: 0.875rem; - width: 1em; - height: 1em; - } - - &:hover { - color: var(--prpl-color-ui-icon-hover); - } - } - - .prpl-suggested-task-snooze { - - &.prpl-toggle-radio-group-open { - - .prpl-snooze-duration-radio-group { - display: block; - } - - .prpl-toggle-radio-group-arrow { - transform: rotate(270deg); - } - } - - legend { - display: block; - width: 100%; - - .prpl-toggle-radio-group { - display: flex; - justify-content: space-between; - width: 100%; - margin-top: 0.5rem; - padding: 0.5rem; - background-color: #fff; - border-radius: var(--prpl-border-radius); - line-height: 1; - text-align: start; - - .prpl-toggle-radio-group-arrow { - transform: rotate(90deg); - } - } - } - - label { - display: block; - background-color: #fff; - padding: 0.5rem; - - &:hover { - background-color: var(--prpl-color-gauge-remain); - } - - input[type="radio"] { - display: none; - } - } - - .prpl-snooze-duration-radio-group { - display: none; - margin-top: 0.75rem; - - label { - border-top: 1px solid #dcdcde; - - &:first-child { - border-top-left-radius: var(--prpl-border-radius); - border-top-right-radius: var(--prpl-border-radius); - border-top: none; - } - - &:last-child { - border-bottom-left-radius: var(--prpl-border-radius); - border-bottom-right-radius: var(--prpl-border-radius); - } - } - - } - } - - &[data-task-action="celebrate"] { - - .tooltip-actions { - pointer-events: none; /* Prevent clicking on actions while celebrating */ - } - } - - .prpl-suggested-task-info { - margin-left: -30px; - - p { - margin-bottom: 0; - } - - p:first-child { - margin-top: 0; - } - } - - /* Disabled checkbox styles. */ - .prpl-suggested-task-disabled-checkbox-tooltip, - .tooltip-actions { - - & > button { - padding: 0; - } - - .prpl-tooltip { - transform: translate(-20%, calc(100% + 10px)); - - &::after { - left: 25px; - right: auto; - transform: translate(-5px, -10px) rotate(90deg); - } - } - } -} diff --git a/assets/css/variables-color.css b/assets/css/variables-color.css index 7ea93ae8d2..dc066bbaf7 100644 --- a/assets/css/variables-color.css +++ b/assets/css/variables-color.css @@ -7,7 +7,6 @@ /* Paper */ --prpl-background-paper: #fff; --prpl-color-border: #d1d5db; - --prpl-color-divider: var(--prpl-color-border); --prpl-color-shadow-paper: #000; /* Graph */ @@ -44,7 +43,6 @@ /* Topics */ --prpl-color-monthly: #faa310; - --prpl-color-monthly-2: #faa310; --prpl-color-streak: var(--prpl-color-monthly); --prpl-color-content-badge: var(--prpl-color-monthly); --prpl-background-monthly: #fff9f0; @@ -76,19 +74,8 @@ /* Button */ --prpl-color-button-primary: #dd324f; --prpl-color-button-primary-hover: #cf2441; - --prpl-color-button-primary-shadow: var(--prpl-color-shadow-paper); - --prpl-color-button-primary-border: none; --prpl-color-button-primary-text: var(--prpl-background-paper); - /* Settings page */ - --prpl-color-setting-pages-icon: var(--prpl-color-monthly); - --prpl-color-setting-posts-icon: var(--prpl-graph-color-4); - --prpl-color-setting-login-icon: var(--prpl-graph-color-3); - --prpl-background-setting-pages: var(--prpl-background-monthly); - --prpl-background-setting-posts: var(--prpl-background-content); - --prpl-background-setting-login: var(--prpl-background-activity); - --prpl-color-border-settings: var(--prpl-color-border); - /* Input field dropdown */ --prpl-color-field-border: var(--prpl-color-border); --prpl-color-text-placeholder: var(--prpl-color-ui-icon); diff --git a/assets/css/web-components/prpl-badge.css b/assets/css/web-components/prpl-badge.css deleted file mode 100644 index 4807362f05..0000000000 --- a/assets/css/web-components/prpl-badge.css +++ /dev/null @@ -1,89 +0,0 @@ -.prpl-badge { - display: grid; - grid-template-columns: 1fr; - - min-width: 0; - gap: 0.5rem; - - > * { - align-self: center; - } - - prpl-badge { - - img { - transition: opacity 0.3s ease-in-out, filter 0.3s ease-in-out; - } - - &[complete="false"] { - - img { - opacity: 0.25; - filter: grayscale(1); - } - } - } -} - -.prpl-previous-month-badge-progress-bars-wrapper { - - h3 { - margin-bottom: 6px; - } - - .prpl-previous-month-badge-progress-bars-wrapper-description { - margin-top: 0; - margin-bottom: 1.25rem; - } - - .prpl-previous-month-badge-progress-bar-wrapper:last-of-type { - padding-bottom: 0 !important; /* override inline style */ - } - - /* Single progress bar wrapper */ - .prpl-previous-month-badge-progress-bar-wrapper { - - .prpl-widget-content-points { - justify-content: flex-start !important; - gap: 1rem; - } - - .prpl-widget-previous-ravi-points-number { - font-size: var(--prpl-font-size-3xl); - font-weight: 600; - } - } -} - - -prpl-badge { - width: 100%; - margin-bottom: 1rem; - - & > img { - vertical-align: bottom; - } - - /* This applies only to the monthly badges. */ - .prpl-previous-month-badge-progress-bars-wrapper &, - .prpl-badge[data-monthly-is-missed="true"] &[complete="false"] { - position: relative; - - &::after { - content: "!"; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - background-color: var(--prpl-color-icon-missed-badge); - border: 2px solid #fff; - border-radius: 50%; - position: absolute; - top: 10%; - right: 25%; - color: #fff; - - } - } -} diff --git a/assets/css/web-components/prpl-install-plugin.css b/assets/css/web-components/prpl-install-plugin.css deleted file mode 100644 index e237ce0aec..0000000000 --- a/assets/css/web-components/prpl-install-plugin.css +++ /dev/null @@ -1,54 +0,0 @@ -prpl-install-plugin { - - button { - display: flex !important; - align-items: center; - justify-content: center; - gap: 0.5rem; - } - - .prpl-install-button-loader { - display: none; - width: 1rem; - height: 1rem; - border: 3px solid var(--prpl-color-link); - border-bottom-color: transparent; - border-radius: 50%; - box-sizing: border-box; - animation: install-button-rotation 1s linear infinite; - } - - button:disabled { - opacity: 0.5; - cursor: not-allowed; - - .prpl-install-button-loader { - display: block; - } - } - - .prpl-button-link { - text-decoration: underline; - color: var(--prpl-color-link); - background: none; - border: none; - padding: 0; - margin: 0; - font-size: inherit; - font-weight: inherit; - line-height: inherit; - text-align: inherit; - cursor: pointer; - } -} - -@keyframes install-button-rotation { - - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} diff --git a/assets/css/web-components/prpl-tooltip.css b/assets/css/web-components/prpl-tooltip.css deleted file mode 100644 index 40dee191e5..0000000000 --- a/assets/css/web-components/prpl-tooltip.css +++ /dev/null @@ -1,97 +0,0 @@ -.tooltip-actions { - justify-content: flex-start; - gap: 0.5em; - display: flex; - flex-wrap: wrap; - position: relative; - - .icon { - width: 1.25rem; - height: 1.25rem; - display: inline-block; - vertical-align: bottom; /* align with the text */ - } -} - -.prpl-tooltip { - position: absolute; - bottom: 0; - left: 100%; - transform: translate(-100%, calc(100% + 10px)); - - padding: 0.75rem 1.5rem 0.75rem 0.75rem; - width: 150px; - background: var(--prpl-background-activity); - border-radius: var(--prpl-border-radius); - z-index: 2; /* above the gauges */ - visibility: hidden; /* hidden by default */ - - font-size: 1rem; - font-weight: 400; - color: var(--prpl-color-text); - - &[data-tooltip-visible="true"] { - visibility: visible; - z-index: 10; - } - - .close, - .prpl-tooltip-close { - position: absolute; - top: 0; - right: 0; - padding: 0.1rem; - line-height: 0; - margin: 0; - background: none; - border: none; - cursor: pointer; - } - - /* Arrow */ - &::after { - content: ""; - position: absolute; - top: 0; - right: 0; - transform: translate(-10px, -10px) rotate(90deg); - - width: 0; - height: 0; - border-style: solid; - border-width: 7.5px 10px 7.5px 0; - border-color: transparent var(--prpl-background-activity) transparent transparent; - } -} - -prpl-tooltip { - display: inline-flex; - align-items: center; - position: relative; - - .prpl-tooltip { - - p { - margin-bottom: 0; - } - - p:first-child { - margin-top: 0; - } - } -} - -.prpl-overlay { - display: none; -} - -body:has([data-tooltip-visible="true"]) .prpl-overlay { - display: block !important; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 9; - background-color: rgba(0, 0, 0, 0.5); -} diff --git a/assets/images/image_onboaring_block.png b/assets/images/image_onboarding_block.png similarity index 100% rename from assets/images/image_onboaring_block.png rename to assets/images/image_onboarding_block.png diff --git a/assets/js/celebrate.js b/assets/js/celebrate.js deleted file mode 100644 index 643d823b76..0000000000 --- a/assets/js/celebrate.js +++ /dev/null @@ -1,121 +0,0 @@ -/* global confetti, prplCelebrate */ -/* - * Confetti. - * - * A script that triggers confetti on the container element. - * - * Dependencies: particles-confetti, progress-planner/suggested-task - */ -/* eslint-disable camelcase */ - -// Create a new custom event to trigger the celebration. -document.addEventListener( 'prpl/celebrateTasks', ( event ) => { - /** - * Trigger the confetti on the container element. - */ - const containerEl = event.detail?.element - ? event.detail.element.closest( '.prpl-suggested-tasks-list' ) - : document.querySelector( - '.prpl-widget-wrapper.prpl-suggested-tasks .prpl-suggested-tasks-list' - ); // If element is not provided, use the default container. - const prplConfettiDefaults = { - spread: 360, - ticks: 50, - gravity: 1, - decay: 0.94, - startVelocity: 30, - shapes: [ 'star' ], - colors: [ 'FFE400', 'FFBD00', 'E89400', 'FFCA6C', 'FDFFB8' ], - }; - - const prplRenderAttemptshoot = () => { - // Get the tasks list position - const origin = containerEl - ? { - x: - ( containerEl.getBoundingClientRect().left + - containerEl.offsetWidth / 2 ) / - window.innerWidth, - y: - ( containerEl.getBoundingClientRect().top + 50 ) / - window.innerHeight, - } - : { x: 0.5, y: 0.3 }; // fallback if list not found - - let confettiOptions = [ - { - particleCount: 30, - scalar: 4, - shapes: [ 'image' ], - shapeOptions: { - image: [ - { src: prplCelebrate.raviIconUrl }, - { src: prplCelebrate.raviIconUrl }, - { src: prplCelebrate.raviIconUrl }, - { src: prplCelebrate.monthIconUrl }, - { src: prplCelebrate.contentIconUrl }, - { src: prplCelebrate.maintenanceIconUrl }, - ], - }, - }, - ]; - - // Tripple check if the confetti options are an array and not undefined. - if ( - 'undefined' !== typeof prplCelebrate.confettiOptions && - true === Array.isArray( prplCelebrate.confettiOptions ) && - prplCelebrate.confettiOptions.length - ) { - confettiOptions = prplCelebrate.confettiOptions; - } - - for ( const value of confettiOptions ) { - // Set confetti options, we do it here so it's applied even if we pass the options from the PHP side (ie hearts confetti). - value.origin = origin; - - confetti( { - ...prplConfettiDefaults, - ...value, - } ); - } - }; - - setTimeout( prplRenderAttemptshoot, 0 ); - setTimeout( prplRenderAttemptshoot, 100 ); - setTimeout( prplRenderAttemptshoot, 200 ); -} ); - -/** - * Remove tasks from the DOM. - * The task will be striked through, before removed, if it has points. - */ -document.addEventListener( 'prpl/removeCelebratedTasks', () => { - document - .querySelectorAll( - '.prpl-suggested-task[data-task-action="celebrate"]' - ) - .forEach( ( item ) => { - // Triggers the strikethrough animation. - item.classList.add( 'prpl-suggested-task-celebrated' ); - - // Remove the item from the DOM. - setTimeout( () => { - item.remove(); - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - }, 2000 ); - } ); -} ); - -/** - * Remove the points (count) from the menu. - */ -document.addEventListener( 'prpl/celebrateTasks', () => { - const points = document.querySelectorAll( - '#adminmenu #toplevel_page_progress-planner .update-plugins' - ); - if ( points ) { - points.forEach( ( point ) => point.remove() ); - } -} ); - -/* eslint-enable camelcase */ diff --git a/assets/js/editor.js b/assets/js/editor.js deleted file mode 100644 index 1bfcd9d8d0..0000000000 --- a/assets/js/editor.js +++ /dev/null @@ -1,735 +0,0 @@ -/* global progressPlannerEditor, prplL10n */ -/** - * Editor script. - * - * Dependencies: wp-plugins, wp-editor, wp-element, wp-components, wp-data, progress-planner/l10n - */ -const { createElement: el, Fragment, useState } = wp.element; -const { registerPlugin } = wp.plugins; -const { PluginSidebar, PluginPostStatusInfo, PluginSidebarMoreMenuItem } = - wp.editor; -const { Button, SelectControl, PanelBody, CheckboxControl, Modal } = - wp.components; -const { useSelect } = wp.data; - -const TAXONOMY = 'progress_planner_page_types'; - -/** - * Get the page type slug from the page type ID. - * - * @param {number} id The page type ID. - * - * @return {string} The page type slug. - */ -const prplGetPageTypeSlugFromId = ( id ) => { - // Check if `id` is an array. - if ( Array.isArray( id ) ) { - id = id.length > 0 ? id[ 0 ] : 0; - } else if ( ! id ) { - id = 0; - } else if ( typeof id === 'string' ) { - id = parseInt( id ); - // Handle NaN from parseInt on invalid strings. - if ( isNaN( id ) ) { - id = 0; - } - } else if ( typeof id !== 'number' ) { - id = 0; - } - - if ( ! id || isNaN( id ) ) { - // Check if progressPlannerEditor exists before accessing its properties. - if ( - typeof progressPlannerEditor !== 'undefined' && - progressPlannerEditor.defaultPageType - ) { - id = parseInt( progressPlannerEditor.defaultPageType ) || 0; - } else { - id = 0; - } - } - - // Check if progressPlannerEditor exists before accessing pageTypes. - if ( - typeof progressPlannerEditor === 'undefined' || - ! progressPlannerEditor.pageTypes - ) { - return undefined; - } - - return progressPlannerEditor.pageTypes.find( - ( pageTypeItem ) => parseInt( pageTypeItem.id ) === parseInt( id ) - )?.slug; -}; - -/** - * Render a dropdown to select the page-type. - * - * @return {Element} Element to render. - */ -const PrplRenderPageTypeSelector = () => { - // Get the current term from the TAXONOMY using useSelect hook. - const currentPageType = useSelect( ( select ) => { - // Defensive check: ensure select and editor store exist. - if ( ! select || typeof select !== 'function' ) { - return 0; - } - const editor = select( 'core/editor' ); - if ( ! editor || typeof editor.getEditedPostAttribute !== 'function' ) { - // Fallback to default if editor store is not available. - if ( - typeof progressPlannerEditor !== 'undefined' && - progressPlannerEditor.defaultPageType - ) { - return parseInt( progressPlannerEditor.defaultPageType ) || 0; - } - return 0; - } - const pageTypeArr = editor.getEditedPostAttribute( TAXONOMY ); - if ( pageTypeArr && 0 < pageTypeArr.length ) { - return parseInt( pageTypeArr[ 0 ] ); - } - // Check if progressPlannerEditor exists before accessing its properties. - if ( - typeof progressPlannerEditor !== 'undefined' && - progressPlannerEditor.defaultPageType - ) { - return parseInt( progressPlannerEditor.defaultPageType ) || 0; - } - return 0; - }, [] ); - - // Bail early if the page types are not set. - // Check if progressPlannerEditor exists before accessing its properties. - if ( - typeof progressPlannerEditor === 'undefined' || - ! progressPlannerEditor.pageTypes || - 0 === progressPlannerEditor.pageTypes.length - ) { - return el( 'div', {}, '' ); - } - - // Build the page types array, to be used in the dropdown. - const pageTypes = []; - progressPlannerEditor.pageTypes.forEach( ( term ) => { - pageTypes.push( { - label: term.title || '', - value: term.id || '', - } ); - } ); - - return el( SelectControl, { - label: prplL10n( 'pageType' ), - value: currentPageType, - options: pageTypes, - onChange: ( value ) => { - // Update the TAXONOMY term value. - const data = {}; - data[ TAXONOMY ] = value; - // Defensive check: ensure wp.data and dispatch exist before calling. - if ( wp.data && typeof wp.data.dispatch === 'function' ) { - const editorDispatch = wp.data.dispatch( 'core/editor' ); - if ( - editorDispatch && - typeof editorDispatch.editPost === 'function' - ) { - editorDispatch.editPost( data ); - } - } - }, - } ); -}; - -/** - * Render the video section. - * This will display a button to open a modal with the video. - * - * @param {Object} props Component props. - * @param {Object} props.lessonSection The lesson section. - * @return {Element} Element to render. - */ -const PrplSectionVideo = ( props ) => { - const [ isOpen, setOpen ] = useState( false ); - const openModal = () => setOpen( true ); - const closeModal = () => setOpen( false ); - - // Handle both direct prop and nested prop for backward compatibility - const lessonSection = props?.lessonSection || props; - - // If no video, return null (component always renders, but conditionally shows content) - if ( ! lessonSection || ! lessonSection.video ) { - return null; - } - - return el( - 'div', - { - title: prplL10n( 'video' ), - initialOpen: false, - }, - el( - 'div', - {}, - el( - Button, - { - key: 'progress-planner-sidebar-video-button', - onClick: openModal, - icon: 'video-alt3', - variant: 'secondary', - style: { - width: '100%', - margin: '15px 0', - color: '#38296D', - boxShadow: 'inset 0 0 0 1px #38296D', - }, - }, - lessonSection.video_button_text - ? lessonSection.video_button_text - : prplL10n( 'watchVideo' ) - ), - isOpen && - el( - Modal, - { - key: 'progress-planner-sidebar-video-modal', - title: prplL10n( 'video' ), - onRequestClose: closeModal, - shouldCloseOnClickOutside: true, - shouldCloseOnEsc: true, - size: 'large', - }, - el( - 'div', - { - key: 'progress-planner-sidebar-video-modal-content', - }, - el( 'div', { - key: 'progress-planner-sidebar-video-modal-content-inner', - dangerouslySetInnerHTML: { - __html: lessonSection.video || '', - }, - } ) - ) - ) - ) - ); -}; - -const PrplSectionHTML = ( lesson, sectionId, wrapperEl = 'div' ) => { - return lesson && lesson[ sectionId ] - ? el( - wrapperEl, - { - key: `progress-planner-sidebar-lesson-section-${ sectionId }`, - title: lesson[ sectionId ].heading || '', - initialOpen: false, - }, - // Always render PrplSectionVideo as a component (not conditionally) - // The component handles the conditional logic internally to avoid hook violations - el( PrplSectionVideo, { lessonSection: lesson[ sectionId ] } ), - lesson[ sectionId ].text - ? el( 'div', { - key: `progress-planner-sidebar-lesson-section-${ sectionId }-content`, - dangerouslySetInnerHTML: { - __html: lesson[ sectionId ].text || '', - }, - } ) - : el( 'div', {}, '' ) - ) - : el( 'div', {}, '' ); -}; - -/** - * Render the lesson items. - * - * @return {Element} Element to render. - */ -const PrplLessonItemsHTML = () => { - const pageTypeID = useSelect( ( select ) => { - // Defensive check: ensure select and editor store exist. - if ( ! select || typeof select !== 'function' ) { - return null; - } - const editor = select( 'core/editor' ); - if ( ! editor || typeof editor.getEditedPostAttribute !== 'function' ) { - return null; - } - return editor.getEditedPostAttribute( TAXONOMY ); - }, [] ); - const pageType = prplGetPageTypeSlugFromId( pageTypeID ); - - const pageTodosMeta = useSelect( ( select ) => { - // Defensive check: ensure select and editor store exist. - if ( ! select || typeof select !== 'function' ) { - return ''; - } - const editor = select( 'core/editor' ); - if ( ! editor || typeof editor.getEditedPostAttribute !== 'function' ) { - return ''; - } - const meta = editor.getEditedPostAttribute( 'meta' ); - return meta ? meta.progress_planner_page_todos : ''; - }, [] ); - const pageTodos = pageTodosMeta || ''; - - // Bail early if the page type or lessons are not set. - // Check if progressPlannerEditor exists before accessing its properties. - if ( - ! pageType || - typeof progressPlannerEditor === 'undefined' || - ! progressPlannerEditor.lessons || - 0 === progressPlannerEditor.lessons.length - ) { - return el( 'div', {}, '' ); - } - - const lesson = progressPlannerEditor.lessons.find( - ( lessonItem ) => lessonItem.settings?.id === pageType - ); - - // Bail early if lesson not found. - if ( ! lesson ) { - return el( 'div', {}, '' ); - } - - // Create a processed copy of the lesson to avoid mutating the original. - const processedLesson = { ...lesson }; - if ( - processedLesson.content_update_cycle && - processedLesson.content_update_cycle.text - ) { - processedLesson.content_update_cycle = { - ...processedLesson.content_update_cycle, - text: processedLesson.content_update_cycle.text - .replace( /\{page_type\}/g, processedLesson.name || '' ) - .replace( - /\{update_cycle\}/g, - processedLesson.content_update_cycle.update_cycle || '' - ), - }; - } - - return el( - Fragment, - { - key: 'progress-planner-sidebar-lesson-items', - }, - // Update cycle content. - PrplSectionHTML( processedLesson, 'content_update_cycle', 'div' ), - - // Intro video & content. - PrplSectionHTML( processedLesson, 'intro', PanelBody ), - - // Checklist video & content. - processedLesson.checklist - ? el( - PanelBody, - { - key: `progress-planner-sidebar-lesson-section-checklist-content`, - title: processedLesson.checklist.heading || '', - initialOpen: false, - }, - el( - 'div', - {}, - // Always render PrplSectionVideo as a component (not conditionally) - // The component handles the conditional logic internally to avoid hook violations - el( PrplSectionVideo, { - lessonSection: processedLesson.checklist, - } ), - PrplTodoProgress( - processedLesson.checklist, - pageTodos - ), - PrplCheckList( processedLesson.checklist, pageTodos ) - ) - ) - : el( 'div', {}, '' ), - - // Writers block video & content. - PrplSectionHTML( processedLesson, 'writers_block', PanelBody ) - ); -}; - -/** - * Render the Progress Planner sidebar. - * This sidebar will display the lessons and videos for the current page. - * - * @return {Element} Element to render. - */ -const PrplProgressPlannerSidebar = () => { - // Use useSelect to reactively detect what's being edited - // Include both postType and postId so component re-renders when switching posts - // postId and postType are destructured but intentionally unused - they're needed - // for reactivity when switching between posts in the site editor. - const { isEditingPost, postId, postType } = useSelect( ( select ) => { - const editor = select( 'core/editor' ); - - // Make sure the editor store and methods exist. - if ( - ! editor || - typeof editor.getCurrentPostType !== 'function' || - typeof editor.getCurrentPostId !== 'function' - ) { - return { - isEditingPost: false, - postId: null, - postType: null, - }; - } - - const currentPostType = editor.getCurrentPostType(); - const currentPostId = editor.getCurrentPostId(); - - // Templates have post types 'wp_template' or 'wp_template_part'. - const isTemplate = - currentPostType === 'wp_template' || - currentPostType === 'wp_template_part'; - - return { - isEditingPost: ! isTemplate && !! currentPostType, - postId: currentPostId, - postType: currentPostType, - }; - }, [] ); - // eslint-disable-next-line no-unused-vars - const _unusedForReactivity = { postId, postType }; - - // Always render the child components to ensure hooks are called consistently. - // Render them in a hidden wrapper when not editing a post to maintain hook order. - const sidebarContent = el( - 'div', - { - key: 'progress-planner-sidebar-page-type-selector-wrapper', - style: { - padding: '15px', - borderBottom: '1px solid #ddd', - }, - }, - // Always render these components so hooks are always called - PrplRenderPageTypeSelector(), - PrplLessonItemsHTML() - ); - - // Only render the PluginSidebar (and its icon) when editing a post - return el( - Fragment, - {}, - // Render child components in a hidden wrapper when not editing to maintain hook order - ! isEditingPost && - el( - 'div', - { - key: 'progress-planner-sidebar-hidden-wrapper', - style: { display: 'none' }, - }, - sidebarContent - ), - // Only show sidebar icon and panel when editing a post - isEditingPost && - el( - Fragment, - {}, - el( - PluginSidebarMoreMenuItem, - { - target: 'progress-planner-sidebar', - key: 'progress-planner-sidebar-menu-item', - }, - prplL10n( 'progressPlannerSidebar' ) - ), - el( - PluginSidebar, - { - name: 'progress-planner-sidebar', - key: 'progress-planner-sidebar-sidebar', - title: prplL10n( 'progressPlannerSidebar' ), - icon: PrplIcon(), - }, - sidebarContent - ) - ) - ); -}; - -/** - * Render the todo items progressbar. - * - * @param {Object} lessonSection The lesson section. - * @param {string} pageTodos - * @return {Element} Element to render. - */ -const PrplTodoProgress = ( lessonSection, pageTodos ) => { - // Get an array of required todo items. - const requiredToDos = []; - if ( lessonSection.todos ) { - lessonSection.todos.forEach( ( toDoGroup ) => { - if ( toDoGroup.group_todos ) { - toDoGroup.group_todos.forEach( ( item ) => { - if ( item.todo_required && item.id ) { - requiredToDos.push( item.id ); - } - } ); - } - } ); - } - - // Get an array of completed todo items. - // Normalize empty strings to empty arrays to avoid [''] from ''.split(',') - const todosArray = pageTodos - ? pageTodos.split( ',' ).filter( Boolean ) - : []; - const completedToDos = todosArray.filter( ( item ) => - requiredToDos.includes( item ) - ); - - // Get the percentage of completed todo items. - // Guard against division by zero. - const percentageComplete = - requiredToDos.length > 0 - ? Math.round( - ( completedToDos.length / requiredToDos.length ) * 100 - ) - : 0; - - return el( - 'div', - {}, - el( - 'div', - { - style: { - width: '100%', - display: 'flex', - alignItems: 'center', - }, - }, - el( - 'div', - { - style: { - width: '100%', - backgroundColor: '#e1e3e7', - height: '15px', - borderRadius: '5px', - }, - }, - el( 'div', { - style: { - width: `${ percentageComplete }%`, - backgroundColor: '#14b8a6', - height: '15px', - borderRadius: '5px', - }, - } ) - ), - el( - 'div', - { - style: { - margin: '0 5px', - fontSize: '12px', - color: '#38296D', - }, - }, - `${ percentageComplete }%` - ) - ), - el( 'div', { - dangerouslySetInnerHTML: { - __html: prplL10n( 'checklistProgressDescription' ), - }, - } ) - ); -}; - -/** - * Render a single todo item with its checkbox. - * - * @param {Object} item - * @param {string} pageTodos - * @return {Element} Element to render. - */ -const PrplCheckListItem = ( item, pageTodos ) => - el( - 'div', - { - key: item.id || '', - }, - el( CheckboxControl, { - checked: - pageTodos && item.id - ? pageTodos - .split( ',' ) - .filter( Boolean ) - .includes( item.id ) - : false, - label: item.todo_name || '', - className: item.todo_required - ? 'progress-planner-todo-item required' - : 'progress-planner-todo-item', - help: el( 'div', { - dangerouslySetInnerHTML: { - __html: item.todo_description || '', - }, - } ), - onChange: ( checked ) => { - // Normalize empty strings to empty arrays. - const toDos = pageTodos - ? pageTodos.split( ',' ).filter( Boolean ) - : []; - if ( checked && item.id ) { - toDos.push( item.id ); - } else if ( item.id ) { - const index = toDos.indexOf( item.id ); - if ( index > -1 ) { - toDos.splice( index, 1 ); - } - } - // Update the `progress_planner_page_todos` meta value. - // Defensive check: ensure wp.data and dispatch exist before calling. - if ( wp.data && typeof wp.data.dispatch === 'function' ) { - const editorDispatch = wp.data.dispatch( 'core/editor' ); - if ( - editorDispatch && - typeof editorDispatch.editPost === 'function' - ) { - editorDispatch.editPost( { - meta: { - progress_planner_page_todos: toDos.join( ',' ), - }, - } ); - } - } - }, - } ) - ); - -/** - * Render the todo items. - * - * @param {Object} lessonSection The lesson section. - * @param {string} pageTodos - * @return {Element} Element to render. - */ -const PrplCheckList = ( lessonSection, pageTodos ) => { - // Bail early if todos are not set. - if ( ! lessonSection.todos || ! Array.isArray( lessonSection.todos ) ) { - return []; - } - - return lessonSection.todos.map( ( toDoGroup ) => - el( - PanelBody, - { - key: `progress-planner-sidebar-lesson-section-${ - toDoGroup.group_heading || '' - }`, - title: toDoGroup.group_heading || '', - initialOpen: false, - }, - el( - 'div', - { - key: `progress-planner-sidebar-lesson-section-${ - toDoGroup.group_heading || '' - }-todos`, - }, - toDoGroup.group_todos && Array.isArray( toDoGroup.group_todos ) - ? toDoGroup.group_todos.map( ( item ) => - PrplCheckListItem( item, pageTodos ) - ) - : [] - ) - ) - ); -}; - -// Register the sidebar. -registerPlugin( 'progress-planner-sidebar', { - render: PrplProgressPlannerSidebar, -} ); - -/** - * Icon Component using branding admin menu icon. - * - * Renders raw SVG inline so it can be styled with CSS (e.g., currentColor). - * - * @return {Element} Element to render. - */ -const PrplIcon = () => - el( 'span', { - className: 'progress-planner-icon', - style: { - display: 'inline-flex', - width: '20px', - height: '20px', - }, - dangerouslySetInnerHTML: { - __html: progressPlannerEditor.adminMenuIconSvg, - }, - } ); - -/** - * Render the Progress Planner post status. - * - * @return {Element} Element to render. - */ -const PrplPostStatus = () => - el( - 'div', - {}, - el( - PluginPostStatusInfo, - {}, - el( - Button, - { - icon: PrplIcon(), - style: { - width: '100%', - margin: '15px 0', - color: '#38296D', - boxShadow: 'inset 0 0 0 1px #38296D', - fontWeight: 'bold', - }, - variant: 'secondary', - href: '#', - onClick: () => { - // openGeneralSidebar is in core/edit-post store, not core/editor. - // Try core/edit-post first (where the method is defined), - // then fallback to core/editor if available in future versions. - const editPostDispatch = - wp.data.dispatch( 'core/edit-post' ); - const editorDispatch = - wp.data.dispatch( 'core/editor' ); - if ( - editPostDispatch && - typeof editPostDispatch.openGeneralSidebar === - 'function' - ) { - editPostDispatch.openGeneralSidebar( - 'progress-planner-sidebar/progress-planner-sidebar' - ); - } else if ( - editorDispatch && - typeof editorDispatch.openGeneralSidebar === - 'function' - ) { - editorDispatch.openGeneralSidebar( - 'progress-planner-sidebar/progress-planner-sidebar' - ); - } - }, - }, - 'Progress Planner' - ) - ), - el( PluginPostStatusInfo, {} ) - ); - -// Register the post status component. -registerPlugin( 'progress-planner-post-status', { - render: PrplPostStatus, -} ); diff --git a/assets/js/focus-element.js b/assets/js/focus-element.js deleted file mode 100644 index 09b56d2e02..0000000000 --- a/assets/js/focus-element.js +++ /dev/null @@ -1,108 +0,0 @@ -/* global progressPlannerFocusElement, prplL10n */ -/** - * focus-element script. - * - * Dependencies: progress-planner/l10n - */ - -const prplGetIndicatorElement = ( content, taskId, points ) => { - // Create an element. - const imgEl = document.createElement( 'img' ); - imgEl.src = - progressPlannerFocusElement.base_url + - '/assets/images/icon_progress_planner.svg'; - imgEl.alt = points - ? prplL10n( 'fixThisIssue' ).replace( '%d', points ) - : ''; - - // Create a span element for the points. - const spanEl = document.createElement( 'span' ); - spanEl.textContent = content; - - // Create a span element for the wrapper. - const wrapperEl = document.createElement( 'span' ); - wrapperEl.classList.add( 'prpl-element-awards-points-icon-wrapper' ); - wrapperEl.setAttribute( 'data-prpl-task-id', taskId ); - - // Add the image and span to the wrapper. - wrapperEl.appendChild( imgEl ); - wrapperEl.appendChild( spanEl ); - - return wrapperEl; -}; - -/** - * Maybe focus on the element, based on the URL. - * - * @param {Object} task The task object. - */ -const prplMaybeFocusOnElement = ( task ) => { - // Check if we want to focus on the element, based on the URL. - const url = new URL( window.location.href ); - const focusOnElement = url.searchParams.get( 'pp-focus-el' ); - if ( focusOnElement === task.task_id ) { - let focused = false; - const iconEls = document.querySelectorAll( - `[data-prpl-task-id="${ task.task_id }"]` - ); - iconEls.forEach( ( el ) => { - el.classList.add( 'focused' ); - if ( ! focused ) { - el.focus(); - el.scrollIntoView( { behavior: 'smooth' } ); - focused = true; - } - } ); - } -}; - -/** - * Add the points indicator to the element. - * - * @param {Object} task The task object. - */ -const prplAddPointsIndicatorToElement = ( task ) => { - const points = task.points || 1; - document.querySelectorAll( task.link_setting.iconEl ).forEach( ( el ) => { - const iconEl = prplGetIndicatorElement( - task.is_complete ? '✓' : '+' + points, - task.task_id, - points - ); - if ( task.is_complete ) { - iconEl.classList.add( 'complete' ); - } - - // Create a positioning wrapper. - const wrapperEl = document.createElement( 'span' ); - wrapperEl.classList.add( - 'prpl-element-awards-points-icon-positioning-wrapper' - ); - - // Add the icon to the wrapper. - wrapperEl.appendChild( iconEl ); - el.appendChild( wrapperEl ); - } ); -}; - -if ( progressPlannerFocusElement.tasks ) { - /** - * Add the points indicator to the element and maybe focus on it. - */ - progressPlannerFocusElement.tasks.forEach( ( task ) => { - prplAddPointsIndicatorToElement( task ); - prplMaybeFocusOnElement( task ); - } ); - - /** - * Add the points indicator to the page title. - */ - const prplPageTitle = document.querySelector( 'h1' ); - const prplPageTitleIndicator = prplGetIndicatorElement( - progressPlannerFocusElement.completedPoints + - '/' + - progressPlannerFocusElement.totalPoints, - 'total' - ); - prplPageTitle.appendChild( prplPageTitleIndicator ); -} diff --git a/assets/js/grid-masonry.js b/assets/js/grid-masonry.js deleted file mode 100644 index 67eb0aedfe..0000000000 --- a/assets/js/grid-masonry.js +++ /dev/null @@ -1,80 +0,0 @@ -/* global prplDocumentReady */ -/* - * Grid Masonry - * - * A script to allow a grid to behave like a masonry layout. - * Inspired by https://medium.com/@andybarefoot/a-masonry-style-layout-using-css-grid-8c663d355ebb - * - * Dependencies: progress-planner/document-ready - */ - -/** - * Trigger a resize event on the grid. - */ -const prplTriggerGridResize = () => { - setTimeout( () => { - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - } ); -}; - -prplDocumentReady( () => { - prplTriggerGridResize(); - setTimeout( prplTriggerGridResize, 1000 ); -} ); - -window.addEventListener( 'resize', prplTriggerGridResize ); - -// Fire event after all images are loaded. -window.addEventListener( 'load', prplTriggerGridResize ); - -window.addEventListener( - 'prpl/grid/resize', - () => { - /** - * Update the grid masonry items (row spans). - */ - document - .querySelectorAll( '.prpl-widget-wrapper' ) - .forEach( ( item ) => { - if ( ! item || item.classList.contains( 'in-popover' ) ) { - return; - } - - const innerContainer = item.querySelector( - '.widget-inner-container' - ); - if ( ! innerContainer ) { - return; - } - - const rowHeight = parseInt( - window - .getComputedStyle( - document.querySelector( '.prpl-widgets-container' ) - ) - .getPropertyValue( 'grid-auto-rows' ) - ); - - const paddingTop = parseInt( - window - .getComputedStyle( item ) - .getPropertyValue( 'padding-top' ) - ); - const paddingBottom = parseInt( - window - .getComputedStyle( item ) - .getPropertyValue( 'padding-bottom' ) - ); - - const rowSpan = Math.ceil( - ( innerContainer.getBoundingClientRect().height + - paddingTop + - paddingBottom ) / - rowHeight - ); - - item.style.gridRowEnd = 'span ' + ( rowSpan + 1 ); - } ); - }, - false -); diff --git a/assets/js/header-filters.js b/assets/js/header-filters.js deleted file mode 100644 index 7eb1bdf92f..0000000000 --- a/assets/js/header-filters.js +++ /dev/null @@ -1,19 +0,0 @@ -// Handle changes to the range dropdown. -document - .getElementById( 'prpl-select-range' ) - .addEventListener( 'change', function () { - const range = this.value; - const url = new URL( window.location.href ); - url.searchParams.set( 'range', range ); - window.location.href = url.href; - } ); - -// Handle changes to the frequency dropdown. -document - .getElementById( 'prpl-select-frequency' ) - .addEventListener( 'change', function () { - const frequency = this.value; - const url = new URL( window.location.href ); - url.searchParams.set( 'frequency', frequency ); - window.location.href = url.href; - } ); diff --git a/assets/js/license-generator.js b/assets/js/license-generator.js index 5dfc2bb109..1c7edcdd1f 100644 --- a/assets/js/license-generator.js +++ b/assets/js/license-generator.js @@ -42,7 +42,6 @@ class LicenseGenerator { * @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: { diff --git a/assets/js/onboarding/OnboardTask.js b/assets/js/onboarding/OnboardTask.js deleted file mode 100644 index 2db23b7816..0000000000 --- a/assets/js/onboarding/OnboardTask.js +++ /dev/null @@ -1,470 +0,0 @@ -/** - * 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 deleted file mode 100644 index 3bda0afeb6..0000000000 --- a/assets/js/onboarding/onboarding.js +++ /dev/null @@ -1,501 +0,0 @@ -/** - * 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 deleted file mode 100644 index 516636fadb..0000000000 --- a/assets/js/onboarding/steps/BadgesStep.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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 deleted file mode 100644 index ff9ffc56ea..0000000000 --- a/assets/js/onboarding/steps/EmailFrequencyStep.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * 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 deleted file mode 100644 index 633f109432..0000000000 --- a/assets/js/onboarding/steps/FirstTaskStep.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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 deleted file mode 100644 index 998d943ade..0000000000 --- a/assets/js/onboarding/steps/MoreTasksStep.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * 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 deleted file mode 100644 index 14a9c8f5ee..0000000000 --- a/assets/js/onboarding/steps/OnboardingStep.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8986db6e74..0000000000 --- a/assets/js/onboarding/steps/SettingsStep.js +++ /dev/null @@ -1,492 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8ad4498be3..0000000000 --- a/assets/js/onboarding/steps/WelcomeStep.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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 deleted file mode 100644 index c46a49c90a..0000000000 --- a/assets/js/onboarding/steps/WhatsWhatStep.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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/recommendations/aioseo-author-archive.js b/assets/js/recommendations/aioseo-author-archive.js deleted file mode 100644 index 5ddac8f983..0000000000 --- a/assets/js/recommendations/aioseo-author-archive.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * All in One SEO: noindex the author archive. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'aioseo-author-archive', - popoverId: 'prpl-popover-aioseo-author-archive', - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_aioseo-author-archive', - nonce: progressPlanner.nonce, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js b/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js deleted file mode 100644 index 4544f78dee..0000000000 --- a/assets/js/recommendations/aioseo-crawl-settings-feed-authors.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * All in One SEO: disable author RSS feeds. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'aioseo-crawl-settings-feed-authors', - popoverId: 'prpl-popover-aioseo-crawl-settings-feed-authors', - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_aioseo-crawl-settings-feed-authors', - nonce: progressPlanner.nonce, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js b/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js deleted file mode 100644 index c0a4777113..0000000000 --- a/assets/js/recommendations/aioseo-crawl-settings-feed-comments.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * All in One SEO: disable global comment RSS feeds. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'aioseo-crawl-settings-feed-comments', - popoverId: 'prpl-popover-aioseo-crawl-settings-feed-comments', - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_aioseo-crawl-settings-feed-comments', - nonce: progressPlanner.nonce, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/aioseo-date-archive.js b/assets/js/recommendations/aioseo-date-archive.js deleted file mode 100644 index d2a5600322..0000000000 --- a/assets/js/recommendations/aioseo-date-archive.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * All in One SEO: noindex the date archive. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'aioseo-date-archive', - popoverId: 'prpl-popover-aioseo-date-archive', - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_aioseo-date-archive', - nonce: progressPlanner.nonce, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/aioseo-media-pages.js b/assets/js/recommendations/aioseo-media-pages.js deleted file mode 100644 index 638b8aa3fe..0000000000 --- a/assets/js/recommendations/aioseo-media-pages.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * All in One SEO: redirect media pages. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'aioseo-media-pages', - popoverId: 'prpl-popover-aioseo-media-pages', - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_aioseo-media-pages', - nonce: progressPlanner.nonce, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/core-blogdescription.js b/assets/js/recommendations/core-blogdescription.js deleted file mode 100644 index c53c9047f5..0000000000 --- a/assets/js/recommendations/core-blogdescription.js +++ /dev/null @@ -1,23 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Core Blog Description recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.siteSettings( { - settingAPIKey: 'description', - setting: 'blogdescription', - taskId: 'core-blogdescription', - popoverId: 'prpl-popover-core-blogdescription', -} ); - -document - .querySelector( 'input#blogdescription' ) - ?.addEventListener( 'input', function ( e ) { - const button = document.querySelector( - '[popover-id="prpl-popover-core-blogdescription"] button[type="submit"]' - ); - button.disabled = e.target.value.length === 0; - } ); diff --git a/assets/js/recommendations/core-permalink-structure.js b/assets/js/recommendations/core-permalink-structure.js deleted file mode 100644 index f9e9849575..0000000000 --- a/assets/js/recommendations/core-permalink-structure.js +++ /dev/null @@ -1,70 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplDocumentReady, progressPlanner */ - -/* - * Set the permalink structure. - * - * Dependencies: progress-planner/recommendations/interactive-task, progress-planner/document-ready - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'core-permalink-structure', - popoverId: 'prpl-popover-core-permalink-structure', - callback: () => { - const customPermalinkStructure = document.querySelector( - '#prpl-popover-core-permalink-structure input[name="prpl_custom_permalink_structure"]' - ); - - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_core-permalink-structure', - nonce: progressPlanner.nonce, - value: customPermalinkStructure.value, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); - -prplDocumentReady( () => { - // Handle custom date format input, this value is what is actually submitted to the server. - const customPermalinkStructureInput = document.querySelector( - '#prpl-popover-core-permalink-structure input[name="prpl_custom_permalink_structure"]' - ); - - // If there is no custom permalink structure input, return. - if ( ! customPermalinkStructureInput ) { - return; - } - - // Handle date format radio button clicks. - document - .querySelectorAll( - '#prpl-popover-core-permalink-structure input[name="prpl_permalink_structure"]' - ) - .forEach( function ( input ) { - input.addEventListener( 'click', function () { - // Dont update the custom permalink structure input if the custom radio button is checked. - if ( 'prpl_permalink_structure_custom_radio' !== this.id ) { - customPermalinkStructureInput.value = this.value; - } - } ); - } ); - - // If users clicks on the custom permalink structure input, check the custom radio button. - customPermalinkStructureInput.addEventListener( 'click', function () { - document.getElementById( - 'prpl_permalink_structure_custom_radio' - ).checked = true; - } ); -} ); diff --git a/assets/js/recommendations/core-siteicon.js b/assets/js/recommendations/core-siteicon.js deleted file mode 100644 index b91380f841..0000000000 --- a/assets/js/recommendations/core-siteicon.js +++ /dev/null @@ -1,195 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplSiteIcon */ -/** - * Core Site Icon recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, wp-api - */ -( function () { - /** - * Core Site Icon class. - */ - class CoreSiteIcon { - /** - * Constructor. - */ - constructor() { - this.mediaUploader = null; - this.elements = this.getElements(); - this.init(); - } - - /** - * Get all DOM elements. - * - * @return {Object} Object containing all DOM elements. - */ - getElements() { - return { - uploadButton: document.getElementById( - 'prpl-upload-site-icon-button' - ), - popover: document.getElementById( - 'prpl-popover-core-siteicon' - ), - hiddenField: document.getElementById( 'prpl-site-icon-id' ), - preview: document.getElementById( 'site-icon-preview' ), - submitButton: document.getElementById( - 'prpl-set-site-icon-button' - ), - }; - } - - /** - * Initialize the component. - */ - init() { - if ( this.elements.uploadButton ) { - this.bindEvents(); - } - this.initFormListener(); - } - - /** - * Bind event listeners. - */ - bindEvents() { - this.elements.uploadButton.addEventListener( 'click', ( e ) => { - this.handleUploadButtonClick( e ); - } ); - } - - /** - * Handle upload button click. - * - * @param {Event} e The click event. - */ - handleUploadButtonClick( e ) { - e.preventDefault(); - - // If the uploader object has already been created, reopen the dialog. - if ( this.mediaUploader ) { - this.mediaUploader.open(); - return; - } - - this.createMediaUploader(); - this.bindMediaUploaderEvents(); - this.mediaUploader.open(); - } - - /** - * Create the media uploader. - */ - createMediaUploader() { - this.mediaUploader = wp.media.frames.file_frame = wp.media( { - title: prplSiteIcon?.mediaTitle || 'Choose Site Icon', - button: { - text: prplSiteIcon?.mediaButtonText || 'Use as Site Icon', - }, - multiple: false, - library: { - type: 'image', - }, - } ); - } - - /** - * Bind media uploader events. - */ - bindMediaUploaderEvents() { - // Hide popover when media library opens. - this.mediaUploader.on( 'open', () => { - if ( this.elements.popover ) { - this.elements.popover.hidePopover(); - } - } ); - - // Show popover when media library closes. - this.mediaUploader.on( 'close', () => { - if ( this.elements.popover ) { - this.elements.popover.showPopover(); - } - } ); - - // Handle image selection. - this.mediaUploader.on( 'select', () => { - this.handleImageSelection(); - } ); - } - - /** - * Handle image selection. - */ - handleImageSelection() { - const attachment = this.mediaUploader - .state() - .get( 'selection' ) - .first() - .toJSON(); - - this.updateHiddenField( attachment ); - this.updatePreview( attachment ); - this.enableSubmitButton(); - } - - /** - * Update the hidden field with attachment ID. - * - * @param {Object} attachment The selected attachment. - */ - updateHiddenField( attachment ) { - if ( this.elements.hiddenField ) { - this.elements.hiddenField.value = attachment.id; - } - } - - /** - * Update the preview with the selected image. - * - * @param {Object} attachment The selected attachment. - */ - updatePreview( attachment ) { - if ( ! this.elements.preview ) { - return; - } - - // Use thumbnail size if available, otherwise use full size. - const imageUrl = - attachment.sizes && attachment.sizes.thumbnail - ? attachment.sizes.thumbnail.url - : attachment.url; - - this.elements.preview.innerHTML = - '' +
-				( attachment.alt || 'Site icon preview' ) +
-				''; - } - - /** - * Enable the submit button. - */ - enableSubmitButton() { - if ( this.elements.submitButton ) { - this.elements.submitButton.disabled = false; - } - } - - /** - * Initialize the form listener. - */ - initFormListener() { - prplInteractiveTaskFormListener.siteSettings( { - settingAPIKey: 'site_icon', - setting: 'site_icon', - taskId: 'core-siteicon', - popoverId: 'prpl-popover-core-siteicon', - settingCallbackValue: ( value ) => parseInt( value, 10 ), - } ); - } - } - - // Initialize the component. - new CoreSiteIcon(); -} )(); diff --git a/assets/js/recommendations/disable-comment-pagination.js b/assets/js/recommendations/disable-comment-pagination.js deleted file mode 100644 index c4aa7c8bb0..0000000000 --- a/assets/js/recommendations/disable-comment-pagination.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Disable Comment Pagination recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.settings( { - setting: 'page_comments', - settingPath: '{}', - taskId: 'disable-comment-pagination', - popoverId: 'prpl-popover-disable-comment-pagination', - settingCallbackValue: () => '', -} ); diff --git a/assets/js/recommendations/disable-comments.js b/assets/js/recommendations/disable-comments.js deleted file mode 100644 index 9eb191c436..0000000000 --- a/assets/js/recommendations/disable-comments.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Disable Comments recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, progress-planner/web-components/prpl-install-plugin - */ - -prplInteractiveTaskFormListener.siteSettings( { - settingAPIKey: 'default_comment_status', - setting: 'default_comment_status', - taskId: 'disable-comments', - popoverId: 'prpl-popover-disable-comments', - settingCallbackValue: () => 'closed', -} ); diff --git a/assets/js/recommendations/hello-world.js b/assets/js/recommendations/hello-world.js deleted file mode 100644 index c76b04f06e..0000000000 --- a/assets/js/recommendations/hello-world.js +++ /dev/null @@ -1,37 +0,0 @@ -/* global prplInteractiveTaskFormListener, helloWorldData */ - -/* - * Core Blog Description recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'hello-world', - popoverId: 'prpl-popover-hello-world', - callback: () => { - return new Promise( ( resolve, reject ) => { - const post = new wp.api.models.Post( { - id: helloWorldData.postId, - } ); - post.fetch() - .then( () => { - // Handle the case when plain URL structure is used, it used to result in invalid URL (404): http://localhost:8080/index.php?rest_route=/wp/v2/prpl_recommendations/35?force=true - const url = post.url().includes( 'rest_route=' ) - ? post.url() + '&force=true' - : post.url() + '?force=true'; - - post.destroy( { url } ) - .then( () => { - resolve( { success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/interactive-task.js b/assets/js/recommendations/interactive-task.js deleted file mode 100644 index bc78f342f0..0000000000 --- a/assets/js/recommendations/interactive-task.js +++ /dev/null @@ -1,307 +0,0 @@ -/* global prplSuggestedTask, progressPlannerAjaxRequest, progressPlanner, prplL10n */ - -/* - * Core Blog Description recommendation. - * - * Dependencies: wp-api, progress-planner/suggested-task, progress-planner/web-components/prpl-interactive-task, progress-planner/ajax-request - */ - -// eslint-disable-next-line no-unused-vars -const prplInteractiveTaskFormListener = { - /** - * Add a form listener to an interactive task form. - * - * @param {Object} options - The options for the interactive task form listener. - * @param {string} options.settingAPIKey - The API key for the setting. - * @param {string} options.setting - The setting to update. - * @param {string} options.taskId - The ID of the task. - * @param {string} options.popoverId - The ID of the popover. - * @param {Function} options.settingCallbackValue - The callback function to get the value of the setting. - */ - siteSettings: ( { - settingAPIKey, - setting, - taskId, - popoverId, - settingCallbackValue = ( value ) => value, - } = {} ) => { - const formElement = document.querySelector( `#${ popoverId } form` ); - - if ( ! formElement ) { - return; - } - - // Add a form listener to the form. - formElement.addEventListener( 'submit', ( event ) => { - event.preventDefault(); - - prplInteractiveTaskFormListener.showLoading( formElement ); - - // Get the form data. - const formData = new FormData( formElement ); - const settingsToPass = {}; - settingsToPass[ settingAPIKey ] = settingCallbackValue( - formData.get( setting ) - ); - - const taskEl = document.querySelector( - `.prpl-suggested-task[data-task-id="${ taskId }"]` - ); - - // Update the blog description. - wp.api.loadPromise.done( () => { - const settings = new wp.api.models.Settings( settingsToPass ); - - settings.save().then( ( response ) => { - const postId = parseInt( taskEl.dataset.postId ); - if ( ! postId ) { - return response; - } - - prplInteractiveTaskFormListener.hideLoading( formElement ); - - // This will trigger the celebration event (confetti) as well. - prplSuggestedTask.maybeComplete( postId ).then( () => { - // Close popover. - document.getElementById( popoverId ).hidePopover(); - } ); - } ); - } ); - } ); - }, - - customSubmit: ( { taskId, popoverId, callback = () => {} } = {} ) => { - const formElement = document.querySelector( `#${ popoverId } form` ); - - if ( ! formElement ) { - return; - } - - const formSubmitHandler = ( event ) => { - event.preventDefault(); - - prplInteractiveTaskFormListener.showLoading( formElement ); - - callback() - .then( ( response ) => { - if ( true !== response.success ) { - // Show error to the user. - prplInteractiveTaskFormListener.showError( - response, - popoverId - ); - - return response; - } - - const taskEl = document.querySelector( - `.prpl-suggested-task[data-task-id="${ taskId }"]` - ); - const postId = parseInt( taskEl.dataset.postId ); - if ( ! postId ) { - return; - } - - // This will trigger the celebration event (confetti) as well. - prplSuggestedTask.maybeComplete( postId ).then( () => { - // Close popover. - document.getElementById( popoverId ).hidePopover(); - } ); - } ) - .catch( ( error ) => { - // Show error to the user. - prplInteractiveTaskFormListener.showError( - error, - popoverId - ); - } ) - .finally( () => { - // Hide loading state. - prplInteractiveTaskFormListener.hideLoading( formElement ); - } ); - }; - - // Add a form listener to the form. - formElement.addEventListener( 'submit', formSubmitHandler ); - - // Remove the form listener when the popover is closed. - document.getElementById( popoverId ).addEventListener( - 'toggle', - ( toggleEvent ) => { - if ( toggleEvent.newState === 'closed' ) { - formElement.removeEventListener( - 'submit', - formSubmitHandler - ); - } - }, - { once: true } - ); - }, - - settings: ( { - taskId, - setting, - settingPath = false, - popoverId, - settingCallbackValue = ( settingValue ) => settingValue, - action = 'prpl_interactive_task_submit', - } = {} ) => { - const formElement = document.querySelector( `#${ popoverId } form` ); - - if ( ! formElement ) { - return; - } - - formElement.addEventListener( 'submit', ( event ) => { - event.preventDefault(); - - prplInteractiveTaskFormListener.showLoading( formElement ); - - const formData = new FormData( formElement ); - const settingsToPass = {}; - settingsToPass[ setting ] = settingCallbackValue( - formData.get( setting ) - ); - - progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action, - _ajax_nonce: progressPlanner.nonce, - post_id: taskId, - setting, - value: settingsToPass[ setting ], - setting_path: settingPath, - }, - } ) - .then( ( response ) => { - if ( true !== response.success ) { - // Show error to the user. - prplInteractiveTaskFormListener.showError( - response, - popoverId - ); - - return response; - } - - const taskEl = document.querySelector( - `.prpl-suggested-task[data-task-id="${ taskId }"]` - ); - - if ( ! taskEl ) { - return response; - } - - const postId = parseInt( taskEl.dataset.postId ); - if ( ! postId ) { - return response; - } - - // This will trigger the celebration event (confetti) as well. - prplSuggestedTask.maybeComplete( postId ).then( () => { - // Close popover. - document.getElementById( popoverId ).hidePopover(); - } ); - } ) - .catch( ( error ) => { - // Show error to the user. - prplInteractiveTaskFormListener.showError( - error, - popoverId - ); - } ) - .finally( () => { - // Hide loading state. - prplInteractiveTaskFormListener.hideLoading( formElement ); - } ); - } ); - }, - - /** - * Helper which shows user an error message. - * For now the error message is generic. - * - * @param {Object} error - The error object. - * @param {string} popoverId - The ID of the popover. - * @return {void} - */ - showError: ( error, popoverId ) => { - const formElement = document.querySelector( `#${ popoverId } form` ); - - if ( ! formElement ) { - return; - } - - console.error( 'Error in interactive task callback:', error ); - - // Check if there's already an error message

element right after the form - const existingErrorElement = formElement.parentNode.querySelector( - 'p.prpl-interactive-task-error-message' - ); - - if ( ! existingErrorElement ) { - // Add paragraph with error message. - const errorParagraph = document.createElement( 'p' ); - errorParagraph.classList.add( - 'prpl-note', - 'prpl-note-error', - 'prpl-interactive-task-error-message' - ); - errorParagraph.textContent = prplL10n( 'somethingWentWrong' ); - - // Append after the form element. - formElement.insertAdjacentElement( 'afterend', errorParagraph ); - } - }, - - /** - * Show loading state. - * - * @param {HTMLFormElement} formElement - The form element. - * @return {void} - */ - showLoading: ( formElement ) => { - let submitButton = formElement.querySelector( 'button[type="submit"]' ); - - if ( ! submitButton ) { - submitButton = formElement.querySelector( - 'button[data-action="completeTask"]' - ); - } - - submitButton.disabled = true; - - // Add spinner. - const spinner = document.createElement( 'span' ); - spinner.classList.add( 'prpl-spinner' ); - spinner.innerHTML = - ''; // WP spinner. - - // Append spinner after submit button. - submitButton.after( spinner ); - }, - - /** - * Hide loading state. - * - * @param {HTMLFormElement} formElement - The form element. - * @return {void} - */ - hideLoading: ( formElement ) => { - let submitButton = formElement.querySelector( 'button[type="submit"]' ); - - if ( ! submitButton ) { - submitButton = formElement.querySelector( - 'button[data-action="completeTask"]' - ); - } - - submitButton.disabled = false; - const spinner = formElement.querySelector( 'span.prpl-spinner' ); - if ( spinner ) { - spinner.remove(); - } - }, -}; diff --git a/assets/js/recommendations/remove-terms-without-posts.js b/assets/js/recommendations/remove-terms-without-posts.js deleted file mode 100644 index 2ed1fbbf67..0000000000 --- a/assets/js/recommendations/remove-terms-without-posts.js +++ /dev/null @@ -1,214 +0,0 @@ -/* global progressPlanner, prplInteractiveTaskFormListener */ -/** - * Remove Terms Without Posts recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, progress-planner/ajax-request, progress-planner/suggested-task - */ -( function () { - /** - * Remove Terms Without Posts class. - */ - class RemoveTermsWithoutPosts { - /** - * Constructor. - */ - constructor() { - this.popoverId = 'prpl-popover-remove-terms-without-posts'; - - // Early return if the popover is not found. - if ( ! document.getElementById( this.popoverId ) ) { - return; - } - - this.currentTermData = null; - this.currentTaskElement = null; - this.elements = this.getElements(); - this.init(); - } - - /** - * Get all DOM elements. - * - * @return {Object} Object containing all DOM elements. - */ - getElements() { - const popover = document.getElementById( this.popoverId ); - return { - popover, - popoverTitle: popover.querySelector( '.prpl-popover-title' ), - - termNameElement: popover.querySelector( - '#prpl-delete-term-name' - ), - taxonomyElement: popover.querySelector( - '#prpl-delete-term-taxonomy' - ), - taxonomyNameElement: popover.querySelector( - '#prpl-delete-term-taxonomy-name' - ), - termIdField: popover.querySelector( '#prpl-delete-term-id' ), - taxonomyField: popover.querySelector( '#prpl-delete-taxonomy' ), - }; - } - - /** - * Initialize the component. - */ - init() { - this.bindEvents(); - } - - /** - * Bind event listeners. - */ - bindEvents() { - // Listen for the generic interactive task action event. - document.addEventListener( - 'prpl-interactive-task-action-remove-terms-without-posts', - ( event ) => { - this.handleInteractiveTaskAction( event ); - - // After the event is handled, initialize the form listener. - this.initFormListener(); - } - ); - } - - /** - * Handle interactive task action event. - * - * @param {CustomEvent} event The custom event with task context data. - */ - handleInteractiveTaskAction( event ) { - this.currentTermData = { - termId: this.decodeHtmlEntities( event.detail.target_term_id ), - taxonomy: this.decodeHtmlEntities( - event.detail.target_taxonomy - ), - taxonomyName: this.decodeHtmlEntities( - event.detail.target_taxonomy_name - ), - termName: this.decodeHtmlEntities( - event.detail.target_term_name - ), - }; - - // Store reference to the task element that triggered this. - this.currentTaskElement = event.target.closest( - '.prpl-suggested-task' - ); - - // Update the popover content with the term data. - this.updatePopoverContent( - this.currentTermData.termId, - this.currentTermData.taxonomy, - this.currentTermData.termName, - this.currentTermData.taxonomyName, - this.decodeHtmlEntities( event.detail.post_title ) - ); - } - - /** - * Update the popover content. - * - * @param {string} termId The term ID. - * @param {string} taxonomy The taxonomy. - * @param {string} termName The term name. - * @param {string} taxonomyName The taxonomy name. - * @param {string} postTitle The post title. - */ - updatePopoverContent( - termId, - taxonomy, - termName, - taxonomyName, - postTitle - ) { - if ( this.elements.popoverTitle ) { - this.elements.popoverTitle.textContent = postTitle; - } - - if ( this.elements.termNameElement ) { - this.elements.termNameElement.textContent = termName; - } - - if ( this.elements.taxonomyElement ) { - this.elements.taxonomyElement.textContent = taxonomy; - } - - if ( this.elements.taxonomyNameElement ) { - this.elements.taxonomyNameElement.textContent = taxonomyName; - } - - if ( this.elements.termIdField ) { - this.elements.termIdField.value = termId; - } - - if ( this.elements.taxonomyField ) { - this.elements.taxonomyField.value = taxonomy; - } - } - - /** - * Initialize the form listener. - */ - initFormListener() { - if ( ! this.currentTermData || ! this.currentTaskElement ) { - return; - } - - prplInteractiveTaskFormListener.customSubmit( { - taskId: this.currentTaskElement.dataset.taskId, - popoverId: this.popoverId, - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': - 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_remove-terms-without-posts', - nonce: progressPlanner.nonce, - term_id: this.elements.termIdField.value, - taxonomy: this.elements.taxonomyField.value, - } ), - } ) - .then( () => { - this.currentTaskElement = null; - this.currentTermData = null; - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, - } ); - } - - /** - * Decodes HTML entities in a string (like ", &, etc.) - * @param {string} str The string to decode. - * @return {string} The decoded string. - */ - decodeHtmlEntities( str ) { - if ( typeof str !== 'string' ) { - return str; - } - - return str - .replace( /"/g, '"' ) - .replace( /'/g, "'" ) - .replace( /</g, '<' ) - .replace( />/g, '>' ) - .replace( /&/g, '&' ); - } - } - - // Initialize the component. - new RemoveTermsWithoutPosts(); -} )(); diff --git a/assets/js/recommendations/rename-uncategorized-category.js b/assets/js/recommendations/rename-uncategorized-category.js deleted file mode 100644 index 0adc4b9128..0000000000 --- a/assets/js/recommendations/rename-uncategorized-category.js +++ /dev/null @@ -1,76 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner, prplDocumentReady */ - -/* - * Rename the Uncategorized category. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'rename-uncategorized-category', - popoverId: 'prpl-popover-rename-uncategorized-category', - callback: () => { - const name = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_name"]' - ); - const slug = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_slug"]' - ); - - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_rename-uncategorized-category', - nonce: progressPlanner.nonce, - uncategorized_category_name: name.value, - uncategorized_category_slug: slug.value, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); - -prplDocumentReady( () => { - const name = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_name"]' - ); - const slug = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_slug"]' - ); - - if ( ! name || ! slug ) { - return; - } - - // Function to check if both fields are valid and toggle button state - const toggleSubmitButton = () => { - const submitButton = document.querySelector( - '#prpl-popover-rename-uncategorized-category button[type="submit"]' - ); - const isNameValid = - name.value && - name.value.toLowerCase() !== name.placeholder.toLowerCase(); - const isSlugValid = - slug.value && - slug.value.toLowerCase() !== slug.placeholder.toLowerCase(); - - submitButton.disabled = ! ( isNameValid && isSlugValid ); - }; - - // If there is no name or slug or it is the same as placeholder the submit button should be disabled. - toggleSubmitButton(); - - // Add event listeners to both fields - name.addEventListener( 'input', toggleSubmitButton ); - slug.addEventListener( 'input', toggleSubmitButton ); -} ); diff --git a/assets/js/recommendations/sample-page.js b/assets/js/recommendations/sample-page.js deleted file mode 100644 index 7b10ed1cb4..0000000000 --- a/assets/js/recommendations/sample-page.js +++ /dev/null @@ -1,37 +0,0 @@ -/* global prplInteractiveTaskFormListener, samplePageData */ - -/* - * Core Blog Description recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'sample-page', - popoverId: 'prpl-popover-sample-page', - callback: () => { - return new Promise( ( resolve, reject ) => { - const post = new wp.api.models.Page( { - id: samplePageData.postId, - } ); - post.fetch() - .then( () => { - // Handle the case when plain URL structure is used, it used to result in invalid URL (404): http://localhost:8080/index.php?rest_route=/wp/v2/prpl_recommendations/35?force=true - const url = post.url().includes( 'rest_route=' ) - ? post.url() + '&force=true' - : post.url() + '?force=true'; - - post.destroy( { url } ) - .then( () => { - resolve( { success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/search-engine-visibility.js b/assets/js/recommendations/search-engine-visibility.js deleted file mode 100644 index 9d4e13139a..0000000000 --- a/assets/js/recommendations/search-engine-visibility.js +++ /dev/null @@ -1,14 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Search Engine Visibility recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'blog_public', - setting: 'blog_public', - taskId: 'search-engine-visibility', - popoverId: 'prpl-popover-search-engine-visibility', - action: 'prpl_interactive_task_submit_search-engine-visibility', -} ); diff --git a/assets/js/recommendations/select-locale.js b/assets/js/recommendations/select-locale.js deleted file mode 100644 index 425fbf87da..0000000000 --- a/assets/js/recommendations/select-locale.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Select Locale recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'language', - setting: 'language', - taskId: 'select-locale', - popoverId: 'prpl-popover-select-locale', - action: 'prpl_interactive_task_submit_select-locale', -} ); diff --git a/assets/js/recommendations/select-timezone.js b/assets/js/recommendations/select-timezone.js deleted file mode 100644 index 35f2607a3a..0000000000 --- a/assets/js/recommendations/select-timezone.js +++ /dev/null @@ -1,26 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplDocumentReady */ - -/* - * Set the site timezone. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'timezone', - setting: 'timezone', - taskId: 'select-timezone', - popoverId: 'prpl-popover-select-timezone', - action: 'prpl_interactive_task_submit_select-timezone', -} ); - -prplDocumentReady( () => { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const timezoneSelect = document.querySelector( 'select#timezone' ); - const timezoneSaved = timezoneSelect?.dataset?.timezoneSaved || 'false'; - - // Try to preselect the timezone. - if ( timezone && timezoneSelect && 'false' === timezoneSaved ) { - timezoneSelect.value = timezone; - } -} ); diff --git a/assets/js/recommendations/set-date-format.js b/assets/js/recommendations/set-date-format.js deleted file mode 100644 index 82a00f13f4..0000000000 --- a/assets/js/recommendations/set-date-format.js +++ /dev/null @@ -1,129 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplDocumentReady, progressPlanner */ - -/* - * Set the site date format. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'set-date-format', - popoverId: 'prpl-popover-set-date-format', - callback: () => { - return new Promise( ( resolve, reject ) => { - const format = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format"]:checked' - ); - const customFormat = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format_custom"]' - ); - - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_set-date-format', - nonce: progressPlanner.nonce, - date_format: format.value, - date_format_custom: customFormat.value, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); - -prplDocumentReady( () => { - // Handle date format radio button clicks - document - .querySelectorAll( - '#prpl-popover-set-date-format input[name="date_format"]' - ) - .forEach( function ( input ) { - input.addEventListener( 'click', function () { - if ( 'date_format_custom_radio' !== this.id ) { - const customInput = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format_custom"]' - ); - const fieldset = customInput.closest( 'fieldset' ); - const exampleElement = fieldset.querySelector( '.example' ); - const formatText = - this.parentElement.querySelector( - '.format-i18n' - ).textContent; - - customInput.value = this.value; - exampleElement.textContent = formatText; - } - } ); - } ); - - // Handle custom date format input - const customDateInput = document.querySelector( - 'input[name="date_format_custom"]' - ); - - if ( customDateInput ) { - customDateInput.addEventListener( 'click', function () { - document.getElementById( - 'date_format_custom_radio' - ).checked = true; - } ); - - customDateInput.addEventListener( 'input', function () { - document.getElementById( - 'date_format_custom_radio' - ).checked = true; - - const format = this; - const fieldset = format.closest( 'fieldset' ); - const example = fieldset.querySelector( '.example' ); - - // Debounce the event callback while users are typing. - clearTimeout( format.dataset.timer ); - format.dataset.timer = setTimeout( function () { - // If custom date is not empty. - if ( format.value ) { - // Find the spinner element within the fieldset - const spinner = fieldset.querySelector( '.spinner' ); - if ( spinner ) { - spinner.classList.add( 'is-active' ); - } - - // Use fetch instead of $.post - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'date_format', - date: format.value, - } ), - } ) - .then( function ( response ) { - return response.text(); - } ) - .then( function ( data ) { - example.textContent = data; - } ) - .catch( function ( error ) { - console.error( 'Error:', error ); - } ) - .finally( function () { - if ( spinner ) { - spinner.classList.remove( 'is-active' ); - } - } ); - } - }, 500 ); - } ); - } -} ); diff --git a/assets/js/recommendations/set-page.js b/assets/js/recommendations/set-page.js deleted file mode 100644 index fbca6755be..0000000000 --- a/assets/js/recommendations/set-page.js +++ /dev/null @@ -1,140 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner, prplDocumentReady */ - -/* - * Set page settings (About, Contact, FAQ, etc.) - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -// Initialize custom submit handlers for all set-page tasks. -prplDocumentReady( function () { - // Find all set-page popovers. - const popovers = document.querySelectorAll( - '[id^="prpl-popover-set-page-"]' - ); - - popovers.forEach( function ( popover ) { - // Extract page name from popover ID (e.g., "prpl-popover-set-page-about" -> "about") - const popoverId = popover.id; - const match = popoverId.match( /prpl-popover-set-page-(.+)/ ); - if ( ! match ) { - return; - } - - const pageName = match[ 1 ]; - const taskId = 'set-page-' + pageName; - - // Skip if already initialized. - if ( popover.dataset.setPageInitialized ) { - return; - } - popover.dataset.setPageInitialized = 'true'; - - prplInteractiveTaskFormListener.customSubmit( { - taskId, - popoverId, - callback: () => { - return new Promise( ( resolve, reject ) => { - const pageValue = document.querySelector( - '#' + - popoverId + - ' input[name="pages[' + - pageName + - '][have_page]"]:checked' - ); - - if ( ! pageValue ) { - reject( { - success: false, - error: new Error( 'Page value not found' ), - } ); - return; - } - - const pageId = document.querySelector( - '#' + - popoverId + - ' select[name="pages[' + - pageName + - '][id]"]' - ); - - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_set-page', - nonce: progressPlanner.nonce, - have_page: pageValue.value, - id: pageId ? pageId.value : '', - task_id: taskId, - } ), - } ) - .then( ( response ) => response.json() ) - .then( ( data ) => { - if ( data.success ) { - resolve( { response: data, success: true } ); - } else { - reject( { success: false, error: data } ); - } - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, - } ); - } ); -} ); - -const prplTogglePageSelectorSettingVisibility = function ( page, value ) { - const itemRadiosWrapperEl = document.querySelector( - `.prpl-pages-item-${ page } .radios` - ); - - if ( ! itemRadiosWrapperEl ) { - return; - } - - const selectPageWrapper = - itemRadiosWrapperEl.querySelector( '.prpl-select-page' ); - - if ( ! selectPageWrapper ) { - return; - } - - // Show only create button. - if ( 'no' === value || 'not-applicable' === value ) { - // Hide wrapper. - selectPageWrapper.style.visibility = 'visible'; - } -}; - -prplDocumentReady( function () { - document - .querySelectorAll( 'input[type="radio"][data-page]' ) - .forEach( function ( radio ) { - const page = radio.getAttribute( 'data-page' ), - value = radio.value; - - if ( radio ) { - // Show/hide the page selector setting if radio is checked. - if ( radio.checked ) { - prplTogglePageSelectorSettingVisibility( page, value ); - } - - // Add listeners for all radio buttons. - radio.addEventListener( 'change', function () { - prplTogglePageSelectorSettingVisibility( page, value ); - } ); - } - } ); -} ); diff --git a/assets/js/recommendations/set-valuable-post-types.js b/assets/js/recommendations/set-valuable-post-types.js deleted file mode 100644 index 218133b48e..0000000000 --- a/assets/js/recommendations/set-valuable-post-types.js +++ /dev/null @@ -1,49 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner */ - -/* - * Set valuable post types. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'set-valuable-post-types', - popoverId: 'prpl-popover-set-valuable-post-types', - callback: () => { - return new Promise( ( resolve, reject ) => { - const postTypes = document.querySelectorAll( - '#prpl-popover-set-valuable-post-types input[name="prpl-post-types-include[]"]:checked' - ); - - if ( ! postTypes.length ) { - reject( { - success: false, - error: new Error( 'No post types selected' ), - } ); - return; - } - - const postTypesValues = Array.from( postTypes ).map( - ( type ) => type.value - ); - - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_set-valuable-post-types', - nonce: progressPlanner.nonce, - 'prpl-post-types-include': postTypesValues, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/update-term-description.js b/assets/js/recommendations/update-term-description.js deleted file mode 100644 index 3eb925e176..0000000000 --- a/assets/js/recommendations/update-term-description.js +++ /dev/null @@ -1,250 +0,0 @@ -/* global progressPlanner, prplInteractiveTaskFormListener */ -/** - * Update Term Description recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, progress-planner/ajax-request, progress-planner/suggested-task - */ -( function () { - /** - * Update Term Description class. - */ - class UpdateTermDescription { - /** - * Constructor. - */ - constructor() { - this.popoverId = 'prpl-popover-update-term-description'; - - // Early return if the popover is not found. - if ( ! document.getElementById( this.popoverId ) ) { - return; - } - - this.currentTermData = null; - this.currentTaskElement = null; - this.elements = this.getElements(); - this.init(); - } - - /** - * Get all DOM elements. - * - * @return {Object} Object containing all DOM elements. - */ - getElements() { - const popover = document.getElementById( this.popoverId ); - - return { - popover, - popoverTitle: popover.querySelector( '.prpl-popover-title' ), - - termNameElement: popover.querySelector( - '#prpl-update-term-name' - ), - taxonomyElement: popover.querySelector( - '#prpl-update-term-taxonomy' - ), - taxonomyNameElement: popover.querySelector( - '#prpl-update-term-taxonomy-name' - ), - termIdField: popover.querySelector( '#prpl-update-term-id' ), - taxonomyField: popover.querySelector( '#prpl-update-taxonomy' ), - descriptionField: popover.querySelector( - '#prpl-term-description' - ), - }; - } - - /** - * Initialize the component. - */ - init() { - this.bindEvents(); - } - - /** - * Bind event listeners. - */ - bindEvents() { - // Listen for the generic interactive task action event. - document.addEventListener( - 'prpl-interactive-task-action-update-term-description', - ( event ) => { - this.handleInteractiveTaskAction( event ); - - // After the event is handled, initialize the form listener. - this.initFormListener(); - } - ); - } - - /** - * Handle interactive task action event. - * - * @param {CustomEvent} event The custom event with task context data. - */ - handleInteractiveTaskAction( event ) { - this.currentTermData = { - termId: this.decodeHtmlEntities( event.detail.target_term_id ), - taxonomy: this.decodeHtmlEntities( - event.detail.target_taxonomy - ), - taxonomyName: this.decodeHtmlEntities( - event.detail.target_taxonomy_name - ), - termName: this.decodeHtmlEntities( - event.detail.target_term_name - ), - }; - - // Store reference to the task element that triggered this. - this.currentTaskElement = event.target.closest( - '.prpl-suggested-task' - ); - - // Update the popover content with the term data. - this.updatePopoverContent( - this.currentTermData.termId, - this.currentTermData.taxonomy, - this.currentTermData.termName, - this.currentTermData.taxonomyName, - this.decodeHtmlEntities( event.detail.post_title ) - ); - } - - /** - * Update the popover content. - * - * @param {string} termId The term ID. - * @param {string} taxonomy The taxonomy. - * @param {string} termName The term name. - * @param {string} taxonomyName The taxonomy name. - * @param {string} postTitle The post title. - */ - updatePopoverContent( - termId, - taxonomy, - termName, - taxonomyName, - postTitle - ) { - if ( this.elements.popoverTitle ) { - this.elements.popoverTitle.textContent = postTitle; - } - - if ( this.elements.termNameElement ) { - this.elements.termNameElement.textContent = termName; - } - - if ( this.elements.taxonomyElement ) { - this.elements.taxonomyElement.textContent = taxonomy; - } - - if ( this.elements.taxonomyNameElement ) { - this.elements.taxonomyNameElement.textContent = taxonomyName; - } - - if ( this.elements.termIdField ) { - this.elements.termIdField.value = termId; - } - - if ( this.elements.taxonomyField ) { - this.elements.taxonomyField.value = taxonomy; - } - - // Clear the description field. - if ( this.elements.descriptionField ) { - this.elements.descriptionField.value = ''; - } - } - - /** - * Initialize the form listener. - */ - initFormListener() { - if ( ! this.currentTermData || ! this.currentTaskElement ) { - return; - } - - const formElement = this.elements.popover.querySelector( 'form' ); - - if ( ! formElement ) { - return; - } - - // Submit button should be disabled if description is empty. - const submitButton = document.getElementById( - 'prpl-update-term-description-button' - ); - - if ( submitButton ) { - submitButton.disabled = true; - } - - // Add event listener to description field. - const descriptionField = formElement.querySelector( - '#prpl-term-description' - ); - if ( descriptionField ) { - descriptionField.addEventListener( 'input', () => { - submitButton.disabled = ! descriptionField.value.trim(); - } ); - } - - prplInteractiveTaskFormListener.customSubmit( { - taskId: this.currentTaskElement.dataset.taskId, - popoverId: this.popoverId, - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': - 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_update-term-description', - nonce: progressPlanner.nonce, - term_id: this.elements.termIdField.value, - taxonomy: this.elements.taxonomyField.value, - description: - this.elements.descriptionField.value, - } ), - } ) - .then( () => { - this.currentTaskElement = null; - this.currentTermData = null; - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, - } ); - } - - /** - * Decodes HTML entities in a string (like ", &, etc.) - * @param {string} str The string to decode. - * @return {string} The decoded string. - */ - decodeHtmlEntities( str ) { - if ( typeof str !== 'string' ) { - return str; - } - - return str - .replace( /"/g, '"' ) - .replace( /'/g, "'" ) - .replace( /</g, '<' ) - .replace( />/g, '>' ) - .replace( /&/g, '&' ); - } - } - - // Initialize the component. - new UpdateTermDescription(); -} )(); diff --git a/assets/js/recommendations/yoast-author-archive.js b/assets/js/recommendations/yoast-author-archive.js deleted file mode 100644 index 78cd23658b..0000000000 --- a/assets/js/recommendations/yoast-author-archive.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast author archive recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo_titles', - settingPath: JSON.stringify( [ 'disable-author' ] ), - taskId: 'yoast-author-archive', - popoverId: 'prpl-popover-yoast-author-archive', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-crawl-settings-emoji-scripts.js b/assets/js/recommendations/yoast-crawl-settings-emoji-scripts.js deleted file mode 100644 index db5ad25e04..0000000000 --- a/assets/js/recommendations/yoast-crawl-settings-emoji-scripts.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast remove emoji scripts recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo', - settingPath: JSON.stringify( [ 'remove_emoji_scripts' ] ), - taskId: 'yoast-crawl-settings-emoji-scripts', - popoverId: 'prpl-popover-yoast-crawl-settings-emoji-scripts', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-crawl-settings-feed-authors.js b/assets/js/recommendations/yoast-crawl-settings-feed-authors.js deleted file mode 100644 index e871be7f71..0000000000 --- a/assets/js/recommendations/yoast-crawl-settings-feed-authors.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast remove post authors feeds recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo', - settingPath: JSON.stringify( [ 'remove_feed_authors' ] ), - taskId: 'yoast-crawl-settings-feed-authors', - popoverId: 'prpl-popover-yoast-crawl-settings-feed-authors', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-crawl-settings-feed-global-comments.js b/assets/js/recommendations/yoast-crawl-settings-feed-global-comments.js deleted file mode 100644 index 4643dbdb6c..0000000000 --- a/assets/js/recommendations/yoast-crawl-settings-feed-global-comments.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast remove global comment feeds recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo', - settingPath: JSON.stringify( [ 'remove_feed_global_comments' ] ), - taskId: 'yoast-crawl-settings-feed-global-comments', - popoverId: 'prpl-popover-yoast-crawl-settings-feed-global-comments', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-date-archive.js b/assets/js/recommendations/yoast-date-archive.js deleted file mode 100644 index 5f9ed45bbd..0000000000 --- a/assets/js/recommendations/yoast-date-archive.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast date archive recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo_titles', - settingPath: JSON.stringify( [ 'disable-date' ] ), - taskId: 'yoast-date-archive', - popoverId: 'prpl-popover-yoast-date-archive', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-format-archive.js b/assets/js/recommendations/yoast-format-archive.js deleted file mode 100644 index 4beef5357b..0000000000 --- a/assets/js/recommendations/yoast-format-archive.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast format archive recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo_titles', - settingPath: JSON.stringify( [ 'disable-post_format' ] ), - taskId: 'yoast-format-archive', - popoverId: 'prpl-popover-yoast-format-archive', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-media-pages.js b/assets/js/recommendations/yoast-media-pages.js deleted file mode 100644 index 36fa395e42..0000000000 --- a/assets/js/recommendations/yoast-media-pages.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Yoast remove global comment feeds recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - setting: 'wpseo_titles', - settingPath: JSON.stringify( [ 'disable-attachment' ] ), - taskId: 'yoast-media-pages', - popoverId: 'prpl-popover-yoast-media-pages', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => true, -} ); diff --git a/assets/js/recommendations/yoast-organization-logo.js b/assets/js/recommendations/yoast-organization-logo.js deleted file mode 100644 index dc70f09c47..0000000000 --- a/assets/js/recommendations/yoast-organization-logo.js +++ /dev/null @@ -1,217 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplYoastOrganizationLogo */ -/** - * Core Site Icon recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, wp-api - */ -( function () { - /** - * Core Site Icon class. - */ - class CoreSiteIcon { - /** - * Constructor. - */ - constructor() { - this.mediaUploader = null; - this.elements = this.getElements(); - this.init(); - } - - /** - * Get all DOM elements. - * - * @return {Object} Object containing all DOM elements. - */ - getElements() { - return { - uploadButton: document.getElementById( - 'prpl-upload-organization-logo-button' - ), - popover: document.getElementById( - 'prpl-popover-yoast-organization-logo' - ), - hiddenField: document.getElementById( - 'prpl-yoast-organization-logo-id' - ), - preview: document.getElementById( 'organization-logo-preview' ), - submitButton: document.getElementById( - 'prpl-set-organization-logo-button' - ), - }; - } - - /** - * Initialize the component. - */ - init() { - if ( this.elements.uploadButton ) { - this.bindEvents(); - } - this.initFormListener(); - } - - /** - * Bind event listeners. - */ - bindEvents() { - this.elements.uploadButton.addEventListener( 'click', ( e ) => { - this.handleUploadButtonClick( e ); - } ); - } - - /** - * Handle upload button click. - * - * @param {Event} e The click event. - */ - handleUploadButtonClick( e ) { - e.preventDefault(); - - // If the uploader object has already been created, reopen the dialog. - if ( this.mediaUploader ) { - this.mediaUploader.open(); - return; - } - - this.createMediaUploader(); - this.bindMediaUploaderEvents(); - this.mediaUploader.open(); - } - - /** - * Create the media uploader. - */ - createMediaUploader() { - this.mediaUploader = wp.media.frames.file_frame = wp.media( { - title: - prplYoastOrganizationLogo?.mediaTitle || 'Choose Site Icon', - button: { - text: - prplYoastOrganizationLogo?.mediaButtonText || - 'Use as Site Icon', - }, - multiple: false, - library: { - type: 'image', - }, - } ); - } - - /** - * Bind media uploader events. - */ - bindMediaUploaderEvents() { - // Hide popover when media library opens. - this.mediaUploader.on( 'open', () => { - if ( this.elements.popover ) { - this.elements.popover.hidePopover(); - } - } ); - - // Show popover when media library closes. - this.mediaUploader.on( 'close', () => { - if ( this.elements.popover ) { - this.elements.popover.showPopover(); - } - } ); - - // Handle image selection. - this.mediaUploader.on( 'select', () => { - this.handleImageSelection(); - } ); - } - - /** - * Handle image selection. - */ - handleImageSelection() { - const attachment = this.mediaUploader - .state() - .get( 'selection' ) - .first() - .toJSON(); - - this.updateHiddenField( attachment ); - this.updatePreview( attachment ); - this.enableSubmitButton(); - } - - /** - * Update the hidden field with attachment ID. - * - * @param {Object} attachment The selected attachment. - */ - updateHiddenField( attachment ) { - if ( this.elements.hiddenField ) { - this.elements.hiddenField.value = attachment.id; - } - } - - /** - * Update the preview with the selected image. - * - * @param {Object} attachment The selected attachment. - */ - updatePreview( attachment ) { - if ( ! this.elements.preview ) { - return; - } - - // Use thumbnail size if available, otherwise use full size. - const imageUrl = - attachment.sizes && attachment.sizes.thumbnail - ? attachment.sizes.thumbnail.url - : attachment.url; - - this.elements.preview.innerHTML = - '' +
-				( attachment.alt || 'Site icon preview' ) +
-				''; - } - - /** - * Enable the submit button. - */ - enableSubmitButton() { - if ( this.elements.submitButton ) { - this.elements.submitButton.disabled = false; - } - } - - /** - * Initialize the form listener. - */ - initFormListener() { - prplInteractiveTaskFormListener.settings( { - setting: 'wpseo_titles', - settingPath: - 'company' === prplYoastOrganizationLogo.companyOrPerson - ? JSON.stringify( [ 'company_logo_id' ] ) - : JSON.stringify( [ 'person_logo_id' ] ), - taskId: 'yoast-organization-logo', - popoverId: 'prpl-popover-yoast-organization-logo', - action: 'prpl_interactive_task_submit', - settingCallbackValue: () => { - const popover = document.getElementById( - 'prpl-popover-yoast-organization-logo' - ); - - if ( ! popover ) { - return false; - } - - const organizationLogoId = popover.querySelector( - 'input[name="prpl_yoast_organization_logo_id"]' - ).value; - return parseInt( organizationLogoId, 10 ); - }, - } ); - } - } - - // Initialize the component. - new CoreSiteIcon(); -} )(); diff --git a/assets/js/suggested-task-terms.js b/assets/js/suggested-task-terms.js deleted file mode 100644 index d6e305c211..0000000000 --- a/assets/js/suggested-task-terms.js +++ /dev/null @@ -1,133 +0,0 @@ -/* global prplDocumentReady */ -/* - * Populate prplSuggestedTasksTerms with the terms for the taxonomies we use. - * - * Dependencies: wp-api-fetch, progress-planner/document-ready - */ - -const prplSuggestedTasksTerms = {}; - -const prplTerms = { - provider: 'prpl_recommendations_provider', - - /** - * Get the terms for a given taxonomy. - * - * @param {string} taxonomy The taxonomy. - * @return {Object} The terms. - */ - // eslint-disable-next-line no-unused-vars - get: ( taxonomy ) => { - if ( 'provider' === taxonomy ) { - taxonomy = prplTerms.provider; - } - return prplSuggestedTasksTerms[ taxonomy ] || {}; - }, - - /** - * Get a promise for the terms collection for a given taxonomy. - * - * @param {string} taxonomy The taxonomy. - * @return {Promise} A promise for the terms collection. - */ - getCollectionPromise: ( taxonomy ) => { - return new Promise( ( resolve ) => { - if ( prplSuggestedTasksTerms[ taxonomy ] ) { - console.info( - `Terms already fetched for taxonomy: ${ taxonomy }` - ); - resolve( prplSuggestedTasksTerms[ taxonomy ] ); - return; - } - - console.info( `Fetching terms for taxonomy: ${ taxonomy }...` ); - - prplSuggestedTasksTerms[ taxonomy ] = - prplSuggestedTasksTerms[ taxonomy ] || {}; - - // Fetch terms using wp.apiFetch. - wp.apiFetch( { - path: `/wp/v2/${ taxonomy }?per_page=100`, - } ) - .then( ( data ) => { - let userTermFound = false; - // 100 is the maximum number of terms that can be fetched in one request. - data.forEach( ( term ) => { - prplSuggestedTasksTerms[ taxonomy ][ term.slug ] = term; - if ( 'user' === term.slug ) { - userTermFound = true; - } - } ); - - if ( userTermFound ) { - resolve( prplSuggestedTasksTerms[ taxonomy ] ); - } else { - // If the `user` term doesn't exist, create it. - wp.apiFetch( { - path: `/wp/v2/${ taxonomy }`, - method: 'POST', - data: { - slug: 'user', - name: 'user', - }, - } ) - .then( ( response ) => { - prplSuggestedTasksTerms[ taxonomy ].user = - response; - resolve( prplSuggestedTasksTerms[ taxonomy ] ); - } ) - .catch( ( error ) => { - console.error( - `Error creating user term for taxonomy: ${ taxonomy }`, - error - ); - // Resolve anyway even if creation fails. - resolve( prplSuggestedTasksTerms[ taxonomy ] ); - } ); - } - } ) - .catch( ( error ) => { - console.error( - `Error fetching terms for taxonomy: ${ taxonomy }`, - error - ); - // Resolve with empty object on error. - resolve( prplSuggestedTasksTerms[ taxonomy ] || {} ); - } ); - } ); - }, - - /** - * Get promises for the terms collections for the taxonomies we use. - * - * @return {Promise} A promise for the terms collections. - */ - getCollectionsPromises: () => { - return new Promise( ( resolve ) => { - prplDocumentReady( () => { - Promise.all( [ - prplTerms.getCollectionPromise( prplTerms.provider ), - ] ).then( () => resolve( prplSuggestedTasksTerms ) ); - } ); - } ); - }, - - /** - * Get a term object from the terms array. - * - * @param {number} termId The term ID. - * @param {string} taxonomy The taxonomy. - * @return {Object} The term object. - */ - getTerm: ( termId, taxonomy ) => { - let termObject = {}; - Object.values( prplSuggestedTasksTerms[ taxonomy ] ).forEach( - ( term ) => { - if ( parseInt( term.id ) === parseInt( termId ) ) { - termObject = term; - } - } - ); - return termObject; - }, -}; diff --git a/assets/js/suggested-task.js b/assets/js/suggested-task.js deleted file mode 100644 index 42bd863074..0000000000 --- a/assets/js/suggested-task.js +++ /dev/null @@ -1,653 +0,0 @@ -/* global HTMLElement, prplSuggestedTask, prplL10n, prplUpdateRaviGauge, prplTerms */ -/* - * Suggested Task scripts & helpers. - * - * Dependencies: wp-api, progress-planner/l10n, progress-planner/suggested-task-terms, progress-planner/web-components/prpl-gauge, progress-planner/widgets/suggested-tasks - */ -/* eslint-disable camelcase, jsdoc/require-param-type, jsdoc/require-param, jsdoc/check-param-names */ - -prplSuggestedTask = { - ...prplSuggestedTask, - injectedItemIds: [], - l10n: { - info: prplL10n( 'info' ), - moveUp: prplL10n( 'moveUp' ), - moveDown: prplL10n( 'moveDown' ), - snooze: prplL10n( 'snooze' ), - disabledRRCheckboxTooltip: prplL10n( 'disabledRRCheckboxTooltip' ), - markAsComplete: prplL10n( 'markAsComplete' ), - taskDelete: prplL10n( 'taskDelete' ), - delete: prplL10n( 'delete' ), - whyIsThisImportant: prplL10n( 'whyIsThisImportant' ), - }, - - /** - * Fetch items for arguments. - * - * @param {Object} args The arguments to pass to the injectItems method. - * @return {Promise} A promise that resolves with the collection of posts. - */ - fetchItems: ( args ) => { - console.info( - `Fetching recommendations with args: ${ JSON.stringify( args ) }...` - ); - - const fetchData = { - status: args.status, - per_page: args.per_page || 100, - _embed: true, - exclude: prplSuggestedTask.injectedItemIds, - filter: { - orderby: 'menu_order', - order: 'ASC', - }, - }; - - // Pass through provider and exclude_provider if provided. - if ( args.provider ) { - fetchData.provider = args.provider; - } - if ( args.exclude_provider ) { - fetchData.exclude_provider = args.exclude_provider; - } - - return prplSuggestedTask - .getPostsCollectionPromise( { data: fetchData } ) - .then( ( response ) => response.data ); - }, - - /** - * Inject items. - * - * @param {Object[]} items The items to inject. - */ - injectItems: ( items ) => { - if ( items.length ) { - // Inject the items into the DOM. - items.forEach( ( item ) => { - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/injectItem', { - detail: { - item, - listId: 'prpl-suggested-tasks-list', - insertPosition: 'beforeend', - }, - } ) - ); - prplSuggestedTask.injectedItemIds.push( item.id ); - } ); - } - - // Trigger the grid resize event. - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - }, - - /** - * Get a collection of posts. - * - * @param {Object} fetchArgs The arguments to pass to the fetch method. - * @return {Promise} A promise that resolves with the collection of posts. - */ - getPostsCollectionPromise: ( fetchArgs ) => { - const collectionsPromise = new Promise( ( resolve ) => { - const postsCollection = - new wp.api.collections.Prpl_recommendations(); - postsCollection - .fetch( fetchArgs ) - .done( ( data ) => resolve( { data, postsCollection } ) ); - } ); - - return collectionsPromise; - }, - - /** - * Render a new item. - * - * @param {Object} post The post object. - */ - getNewItemTemplatePromise: ( { post = {}, listId = '' } ) => - new Promise( ( resolve ) => { - const { prpl_recommendations_provider } = post; - const terms = { prpl_recommendations_provider }; - - Object.values( prplTerms.get( 'provider' ) ).forEach( ( term ) => { - if ( term.id === terms[ prplTerms.provider ][ 0 ] ) { - terms[ prplTerms.provider ] = term; - } - } ); - - const template = wp.template( 'prpl-suggested-task' ); - const data = { - post, - terms, - listId, - assets: prplSuggestedTask.assets, - action: 'pending' === post.status ? 'celebrate' : '', - l10n: prplSuggestedTask.l10n, - }; - - resolve( template( data ) ); - } ), - - /** - * Run a task action. - * - * @param {number} postId The post ID. - * @param {string} actionType The action type. - * @return {Promise} A promise that resolves with the response from the server. - */ - runTaskAction: ( postId, actionType ) => - wp.ajax.post( 'progress_planner_suggested_task_action', { - post_id: postId, - nonce: prplSuggestedTask.nonce, - action_type: actionType, - } ), - - /** - * Trash (delete) a task. - * Only user tasks can be trashed. - * - * @param {number} postId The post ID. - */ - trash: ( postId ) => { - const post = new wp.api.models.Prpl_recommendations( { - id: postId, - } ); - post.fetch().then( () => { - // Handle the case when plain URL structure is used, it used to result in invalid URL (404): http://localhost:8080/index.php?rest_route=/wp/v2/prpl_recommendations/35?force=true - const url = post.url().includes( 'rest_route=' ) - ? post.url() + '&force=true' - : post.url() + '?force=true'; - - post.destroy( { url } ).then( () => { - // Remove the task from the todo list. - prplSuggestedTask.removeTaskElement( postId ); - - // Fetch and inject a replacement task - prplSuggestedTask.fetchAndInjectReplacementTask(); - - setTimeout( - () => - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ), - 500 - ); - - prplSuggestedTask.runTaskAction( postId, 'delete' ); - } ); - } ); - }, - - /** - * Maybe complete a task. - * - * @param {number} postId The post ID. - */ - maybeComplete: ( postId ) => { - // Return the promise chain so callers can wait for completion - return new Promise( ( resolve, reject ) => { - // Get the task. - const post = new wp.api.models.Prpl_recommendations( { - id: postId, - } ); - post.fetch() - .then( ( postData ) => { - const taskProviderId = prplTerms.getTerm( - postData?.[ prplTerms.provider ], - prplTerms.provider - ).slug; - - const el = prplSuggestedTask.getTaskElement( postId ); - - // Dismissable tasks don't have pending status, it's either publish or trash. - const newStatus = - 'publish' === postData.status ? 'trash' : 'publish'; - - // Disable the checkbox for RR tasks, to prevent multiple clicks. - el.querySelector( - '.prpl-suggested-task-checkbox' - )?.setAttribute( 'disabled', 'disabled' ); - - post.set( 'status', newStatus ) - .save() - .then( () => { - prplSuggestedTask.runTaskAction( - postId, - 'trash' === newStatus ? 'complete' : 'pending' - ); - const eventPoints = parseInt( - postData?.prpl_points - ); - - // Task is trashed, check if we need to celebrate. - if ( 'trash' === newStatus ) { - el.setAttribute( - 'data-task-action', - 'celebrate' - ); - if ( 'user' === taskProviderId ) { - // Set class to trigger strike through animation. - el.classList.add( - 'prpl-suggested-task-celebrated' - ); - - setTimeout( () => { - // Move task from published to trash. - document - .getElementById( - 'todo-list-completed' - ) - .insertAdjacentElement( - 'beforeend', - el - ); - - // Remove the class to trigger the strike through animation. - el.classList.remove( - 'prpl-suggested-task-celebrated' - ); - - window.dispatchEvent( - new CustomEvent( - 'prpl/grid/resize' - ) - ); - - // Remove the disabled attribute for user tasks, so they can be clicked again. - el.querySelector( - '.prpl-suggested-task-checkbox' - )?.removeAttribute( 'disabled' ); - - // Resolve the promise after the timeout completes - resolve( { - postId, - newStatus, - eventPoints, - } ); - }, 2000 ); - } else { - // Check the chekcbox, since completing task can be triggered in different ways ("Mark as done" button), without triggering the onchange event. - const checkbox = el.querySelector( - '.prpl-suggested-task-checkbox' - ); - if ( checkbox ) { - checkbox.checked = true; - } - - /** - * Strike completed tasks and remove them from the DOM. - */ - document.dispatchEvent( - new CustomEvent( - 'prpl/removeCelebratedTasks' - ) - ); - - // Fetch and inject a replacement task for non-user tasks - prplSuggestedTask.fetchAndInjectReplacementTask(); - - // Resolve immediately for non-user tasks - resolve( { - postId, - newStatus, - eventPoints, - } ); - } - - // We trigger celebration only if the task has points. - if ( 0 < eventPoints ) { - prplUpdateRaviGauge( eventPoints ); - - // Trigger the celebration event (confetti). - document.dispatchEvent( - new CustomEvent( - 'prpl/celebrateTasks', - { - detail: { element: el }, - } - ) - ); - } - } else if ( - 'publish' === newStatus && - 'user' === taskProviderId - ) { - // This is only possible for user tasks. - // Set the task action to publish. - el.setAttribute( - 'data-task-action', - 'publish' - ); - - // Update the Ravi gauge. - prplUpdateRaviGauge( 0 - eventPoints ); - - // Move task from trash to published, tasks with points go to the beginning of the list. - document - .getElementById( 'todo-list' ) - .insertAdjacentElement( - 0 < eventPoints - ? 'afterbegin' - : 'beforeend', - el - ); - - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ); - - // Remove the disabled attribute for user tasks, so they can be clicked again. - el.querySelector( - '.prpl-suggested-task-checkbox' - )?.removeAttribute( 'disabled' ); - - // Resolve immediately for publish actions - resolve( { postId, newStatus, eventPoints } ); - } - } ) - .catch( reject ); - } ) - .catch( reject ); - } ); - }, - - /** - * Snooze a task. - * - * @param {number} postId The post ID. - * @param {string} snoozeDuration The snooze duration. - */ - snooze: ( postId, snoozeDuration ) => { - const snoozeDurationMap = { - '1-week': 7, - '2-weeks': 14, - '1-month': 30, - '3-months': 90, - '6-months': 180, - '1-year': 365, - forever: 3650, - }; - - const snoozeDurationDays = snoozeDurationMap[ snoozeDuration ]; - const date = new Date( - Date.now() + snoozeDurationDays * 24 * 60 * 60 * 1000 - ) - .toISOString() - .split( '.' )[ 0 ]; - const postModelToSave = new wp.api.models.Prpl_recommendations( { - id: postId, - status: 'future', - date, - date_gmt: date, - } ); - postModelToSave.save().then( () => { - prplSuggestedTask.removeTaskElement( postId ); - - // Fetch and inject a replacement task - prplSuggestedTask.fetchAndInjectReplacementTask(); - } ); - }, - - /** - * Run a tooltip action. - * - * @param {HTMLElement} button The button that was clicked. - */ - runButtonAction: ( button ) => { - let action = button.getAttribute( 'data-action' ); - const target = button.getAttribute( 'data-target' ); - const item = button.closest( 'li.prpl-suggested-task' ); - const tooltipActions = item.querySelector( '.tooltip-actions' ); - const elClass = '.prpl-suggested-task-' + target; - - // If the tooltip was already open, close it. - if ( - !! tooltipActions.querySelector( - `${ elClass }[data-tooltip-visible]` - ) - ) { - action = 'close-' + target; - } else { - const closestTaskListVisible = item - .closest( '.prpl-suggested-tasks-list' ) - .querySelector( `[data-tooltip-visible]` ); - // Close the any opened radio group. - closestTaskListVisible?.classList.remove( - 'prpl-toggle-radio-group-open' - ); - // Remove any existing tooltip visible attribute, in the entire list. - closestTaskListVisible?.removeAttribute( 'data-tooltip-visible' ); - } - - switch ( action ) { - case 'move-up': - case 'move-down': - if ( 'move-up' === action && item.previousElementSibling ) { - item.parentNode.insertBefore( - item, - item.previousElementSibling - ); - } else if ( - 'move-down' === action && - item.nextElementSibling - ) { - item.parentNode.insertBefore( - item.nextElementSibling, - item - ); - } - // Trigger a custom event. - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/move', { - detail: { node: item }, - } ) - ); - break; - } - }, - - /** - * Update the task title. - * - * @param {HTMLElement} el The element that was edited. - */ - updateTaskTitle: ( el ) => { - // Add debounce to the input event. - clearTimeout( this.debounceTimeout ); - this.debounceTimeout = setTimeout( () => { - // Update an existing post. - const title = el.textContent.replace( /\n/g, '' ); - const postModel = new wp.api.models.Prpl_recommendations( { - id: parseInt( el.getAttribute( 'data-post-id' ) ), - title, - } ); - postModel.save().then( () => - // Update the task title. - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/update', { - detail: { - node: el.closest( 'li.prpl-suggested-task' ), - }, - } ) - ) - ); - el - .closest( 'li.prpl-suggested-task' ) - .querySelector( - 'label:has(.prpl-suggested-task-checkbox) .screen-reader-text' - ).innerHTML = `${ title }: ${ prplL10n( 'markAsComplete' ) }`; - }, 300 ); - }, - - /** - * Prevent Enter key in contenteditable elements. - * - * @param {Event} event The keydown event. - */ - preventEnterKey: ( event ) => { - if ( event.key === 'Enter' ) { - event.preventDefault(); - event.stopPropagation(); - event.target.blur(); - return false; - } - }, - - /** - * Get the task element. - * - * @param {number} postId The post ID. - * @return {HTMLElement} The task element. - */ - getTaskElement: ( postId ) => - document.querySelector( - `.prpl-suggested-task[data-post-id="${ postId }"]` - ), - - /** - * Remove the task element. - * - * @param {number} postId The post ID. - */ - removeTaskElement: ( postId ) => - prplSuggestedTask.getTaskElement( postId )?.remove(), - - /** - * Fetch and inject a replacement task after one is removed. - * - * Replacement tasks are always fetched for the suggested-tasks-list, - * which excludes user tasks (user tasks have their own todo list). - */ - fetchAndInjectReplacementTask: () => { - // Collect all currently visible task IDs from the DOM - const visibleTaskIds = Array.from( - document.querySelectorAll( '.prpl-suggested-task[data-post-id]' ) - ).map( ( el ) => parseInt( el.getAttribute( 'data-post-id' ) ) ); - - // Combine with injectedItemIds to ensure we have a complete exclusion list - const allTaskIds = [ - ...new Set( [ - ...prplSuggestedTask.injectedItemIds, - ...visibleTaskIds, - ] ), - ]; - - // Update injectedItemIds to include any tasks that might have been missed - prplSuggestedTask.injectedItemIds = allTaskIds; - - const fetchArgs = { - status: 'publish', - per_page: 1, - exclude_provider: 'user', // Always exclude user tasks from suggested-tasks-list - }; - - prplSuggestedTask.fetchItems( fetchArgs ).then( ( items ) => { - if ( items && items.length > 0 ) { - prplSuggestedTask.injectItems( items ); - } - } ); - }, -}; - -/** - * Inject an item. - */ -document.addEventListener( 'prpl/suggestedTask/injectItem', ( event ) => { - prplSuggestedTask - .getNewItemTemplatePromise( { - post: event.detail.item, - listId: event.detail.listId, - } ) - .then( ( itemHTML ) => { - /** - * @todo Implement the parent task functionality. - * Use this code: `const parent = event.detail.item.parent && '' !== event.detail.item.parent ? event.detail.item.parent : null; - */ - const parent = false; - - if ( ! parent ) { - // Inject the item into the list. - document - .getElementById( event.detail.listId ) - .insertAdjacentHTML( - event.detail.insertPosition, - itemHTML - ); - - // Trigger the grid resize event. - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - - return; - } - - // If we could not find the parent item, try again after 500ms. - window.prplRenderAttempts = window.prplRenderAttempts || 0; - if ( window.prplRenderAttempts > 20 ) { - return; - } - const parentItem = document.querySelector( - `.prpl-suggested-task[data-task-id="${ parent }"]` - ); - if ( ! parentItem ) { - setTimeout( () => { - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/injectItem', { - detail: { - item: event.detail.item, - listId: event.detail.listId, - insertPosition: event.detail.insertPosition, - }, - } ) - ); - window.prplRenderAttempts++; - }, 100 ); - return; - } - - // If the child list does not exist, create it. - if ( - ! parentItem.querySelector( '.prpl-suggested-task-children' ) - ) { - const childListElement = document.createElement( 'ul' ); - childListElement.classList.add( - 'prpl-suggested-task-children' - ); - parentItem.appendChild( childListElement ); - } - - // Inject the item into the child list. - parentItem - .querySelector( '.prpl-suggested-task-children' ) - .insertAdjacentHTML( 'beforeend', itemHTML ); - } ); -} ); - -// When the 'prpl/suggestedTask/move' event is triggered, -// update the menu_order of the todo items. -document.addEventListener( 'prpl/suggestedTask/move', ( event ) => { - const listUl = event.detail.node.closest( 'ul' ); - const todoItemsIDs = []; - // Get all the todo items. - const todoItems = listUl.querySelectorAll( '.prpl-suggested-task' ); - let menuOrder = 0; - todoItems.forEach( ( todoItem ) => { - const itemID = parseInt( todoItem.getAttribute( 'data-post-id' ) ); - todoItemsIDs.push( itemID ); - todoItem.setAttribute( 'data-task-order', menuOrder ); - - listUl - .querySelector( `.prpl-suggested-task[data-post-id="${ itemID }"]` ) - .setAttribute( 'data-task-order', menuOrder ); - - // Update an existing post. - const post = new wp.api.models.Prpl_recommendations( { - id: itemID, - menu_order: menuOrder, - } ); - post.save(); - menuOrder++; - } ); -} ); - -/* eslint-enable camelcase, jsdoc/require-param-type, jsdoc/require-param, jsdoc/check-param-names */ diff --git a/assets/js/tour.js b/assets/js/tour.js index c57ccb41fc..3ae83287c8 100644 --- a/assets/js/tour.js +++ b/assets/js/tour.js @@ -28,13 +28,6 @@ const prplDriverObj = prplDriver( { popover, // eslint-disable-line no-unused-vars { config, state } // eslint-disable-line no-unused-vars ) => { - const monthlyBadgesPopover = document.getElementById( - 'prpl-popover-monthly-badges' - ); - if ( state.activeIndex === 5 ) { - prplTourShowPopover( monthlyBadgesPopover ); - } - // Update URL with current step. const newUrl = new URL( window.location ); newUrl.searchParams.set( 'tour_step', state.activeIndex ); @@ -42,46 +35,8 @@ const prplDriverObj = prplDriver( { }, } ); -function prplTourShowPopover( popover ) { - popover.showPopover(); - prplMakePopoverBackdropTransparent( popover ); -} - -function prplTourHidePopover( popover ) { - popover.hidePopover(); - document.getElementById( popover.id + '-backdrop-transparency' ).remove(); -} - -// Function to make the backdrop of a popover transparent. -function prplMakePopoverBackdropTransparent( popover ) { - if ( popover ) { - const style = document.createElement( 'style' ); - style.id = popover.id + '-backdrop-transparency'; - style.innerHTML = ` - #${ popover.id }::backdrop { - background-color: transparent !important; - } - `; - document.head.appendChild( style ); - } -} - // eslint-disable-next-line no-unused-vars -- This is called on a few buttons. function prplStartTour() { - const monthlyBadgesPopover = document.getElementById( - 'prpl-popover-monthly-badges' - ); - const progressPlannerTourSteps = progressPlannerTour.steps; - - progressPlannerTourSteps[ 4 ].popover.onNextClick = function () { - prplTourShowPopover( monthlyBadgesPopover ); - prplDriverObj.moveNext(); - }; - progressPlannerTourSteps[ 5 ].popover.onNextClick = function () { - prplTourHidePopover( monthlyBadgesPopover ); - prplDriverObj.moveNext(); - }; - // Check URL parameters. const urlParams = new URLSearchParams( window.location.search ); const savedStepIndex = urlParams.get( 'tour_step' ); @@ -102,7 +57,10 @@ function prplStartTour() { ); } -// Add event listener for tour button. -document - .getElementById( 'prpl-start-tour-icon-button' ) - ?.addEventListener( 'click', prplStartTour ); +// Add event listener for tour button using event delegation. +// This is necessary because the button is rendered by React after this script loads. +document.addEventListener( 'click', ( event ) => { + if ( event.target.closest( '#prpl-start-tour-icon-button' ) ) { + prplStartTour(); + } +} ); diff --git a/assets/js/upgrade-tasks.js b/assets/js/upgrade-tasks.js index 2b94f84344..d0d4b165ff 100644 --- a/assets/js/upgrade-tasks.js +++ b/assets/js/upgrade-tasks.js @@ -93,26 +93,21 @@ const prplOnboardRedirect = () => { } }; -// Trigger the onboarding tasks popover if it is in the DOM. +// Trigger the onboarding tasks animation if the React component is rendered. +// The React component will handle the popover display, but we can still animate tasks. prplDocumentReady( function () { - const popover = document.getElementById( 'prpl-popover-upgrade-tasks' ); - if ( popover ) { - popover.showPopover(); - - prplOnboardTasks().then( () => { - document - .getElementById( 'prpl-onboarding-continue-button' ) - .classList.remove( 'prpl-disabled' ); - } ); - - // Click on the close popover button should also redirect to the PP Dashboard page. - const closePopoverButton = document.querySelector( - '#prpl-popover-upgrade-tasks .prpl-popover-close' - ); - if ( closePopoverButton ) { - closePopoverButton.addEventListener( 'click', () => { - prplOnboardRedirect(); + // Wait for React to render, then animate tasks. + setTimeout( () => { + const tasksElement = document.getElementById( 'prpl-onboarding-tasks' ); + if ( tasksElement ) { + prplOnboardTasks().then( () => { + const continueButton = document.getElementById( + 'prpl-onboarding-continue-button' + ); + if ( continueButton ) { + continueButton.classList.remove( 'prpl-disabled' ); + } } ); } - } + }, 500 ); } ); diff --git a/assets/js/vendor/tsparticles.confetti.bundle.min.js b/assets/js/vendor/tsparticles.confetti.bundle.min.js deleted file mode 100644 index eaf810c097..0000000000 --- a/assets/js/vendor/tsparticles.confetti.bundle.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! For license information please see tsparticles.confetti.bundle.min.js.LICENSE.txt */ -!function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var i=e();for(var s in i)("object"==typeof exports?exports:t)[s]=i[s]}}(this,(()=>(()=>{"use strict";var t={d:(e,i)=>{for(var s in i)t.o(i,s)&&!t.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:i[s]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};t.r(e),t.d(e,{AnimatableColor:()=>De,AnimationOptions:()=>Ce,AnimationValueWithRandom:()=>Ee,Background:()=>le,BackgroundMask:()=>de,BackgroundMaskCover:()=>he,Circle:()=>vi,ClickEvent:()=>pe,Collisions:()=>Ae,CollisionsAbsorb:()=>Oe,CollisionsOverlap:()=>Te,ColorAnimation:()=>Se,DivEvent:()=>fe,Events:()=>ge,ExternalInteractorBase:()=>Di,FullScreen:()=>ue,HoverEvent:()=>ve,HslAnimation:()=>ke,HslColorManager:()=>Si,Interactivity:()=>be,ManualParticle:()=>xe,Modes:()=>we,Move:()=>Xe,MoveAngle:()=>Ve,MoveAttract:()=>Ue,MoveCenter:()=>$e,MoveGravity:()=>qe,MovePath:()=>Ge,MoveTrail:()=>je,Opacity:()=>Ze,OpacityAnimation:()=>Ye,Options:()=>li,OptionsColor:()=>ce,OutModes:()=>We,Parallax:()=>me,ParticlesBounce:()=>Le,ParticlesBounceFactor:()=>Fe,ParticlesDensity:()=>Qe,ParticlesInteractorBase:()=>Oi,ParticlesNumber:()=>Ke,ParticlesNumberLimit:()=>Je,ParticlesOptions:()=>ai,Point:()=>pi,Range:()=>fi,RangedAnimationOptions:()=>Pe,RangedAnimationValueWithRandom:()=>Re,Rectangle:()=>mi,ResizeEvent:()=>ye,Responsive:()=>_e,RgbColorManager:()=>ki,Shadow:()=>ti,Shape:()=>ei,Size:()=>si,SizeAnimation:()=>ii,Spin:()=>Ne,Stroke:()=>oi,Theme:()=>Me,ThemeDefault:()=>ze,ValueWithRandom:()=>Ie,Vector:()=>v,Vector3d:()=>m,ZIndex:()=>ni,addColorManager:()=>St,addEasing:()=>w,alterHsl:()=>se,areBoundsInside:()=>et,arrayRandomIndex:()=>J,calcExactPositionOrRandomFromSize:()=>B,calcExactPositionOrRandomFromSizeRanged:()=>V,calcPositionFromSize:()=>F,calcPositionOrRandomFromSize:()=>L,calcPositionOrRandomFromSizeRanged:()=>A,calculateBounds:()=>it,circleBounce:()=>lt,circleBounceDataFromParticle:()=>ct,clamp:()=>z,clear:()=>Zt,collisionVelocity:()=>R,colorMix:()=>$t,colorToHsl:()=>Tt,colorToRgb:()=>Ot,confetti:()=>ro,deepExtend:()=>st,divMode:()=>rt,divModeExecute:()=>nt,drawEffect:()=>Jt,drawLine:()=>Nt,drawParticle:()=>Qt,drawParticlePlugin:()=>ie,drawPlugin:()=>ee,drawShape:()=>Kt,drawShapeAfterDraw:()=>te,errorPrefix:()=>f,executeOnSingleOrMultiple:()=>dt,findItemFromSingleOrMultiple:()=>pt,generatedAttribute:()=>i,getDistance:()=>T,getDistances:()=>O,getEasing:()=>b,getHslAnimationFromHsl:()=>jt,getHslFromAnimation:()=>Ht,getLinkColor:()=>qt,getLinkRandomColor:()=>Gt,getLogger:()=>G,getParticleBaseVelocity:()=>E,getParticleDirectionAngle:()=>I,getPosition:()=>vt,getRandom:()=>_,getRandomRgbColor:()=>Bt,getRangeMax:()=>k,getRangeMin:()=>S,getRangeValue:()=>P,getSize:()=>yt,getStyleFromHsl:()=>Ut,getStyleFromRgb:()=>Vt,hasMatchMedia:()=>W,hslToRgb:()=>Lt,hslaToRgba:()=>At,initParticleNumericAnimationValue:()=>ft,isArray:()=>zt,isBoolean:()=>gt,isDivModeEnabled:()=>ot,isFunction:()=>xt,isInArray:()=>Z,isNumber:()=>bt,isObject:()=>_t,isPointInside:()=>tt,isSsr:()=>j,isString:()=>wt,itemFromArray:()=>K,itemFromSingleOrMultiple:()=>ut,loadFont:()=>Q,loadOptions:()=>ri,loadParticlesOptions:()=>ci,mix:()=>M,mouseDownEvent:()=>s,mouseLeaveEvent:()=>n,mouseMoveEvent:()=>r,mouseOutEvent:()=>a,mouseUpEvent:()=>o,paintBase:()=>Xt,paintImage:()=>Yt,parseAlpha:()=>U,randomInRange:()=>C,rangeColorToHsl:()=>It,rangeColorToRgb:()=>Dt,rectBounce:()=>ht,resizeEvent:()=>u,rgbToHsl:()=>Et,safeIntersectionObserver:()=>X,safeMatchMedia:()=>N,safeMutationObserver:()=>Y,setLogger:()=>q,setRandom:()=>x,setRangeValue:()=>D,singleDivModeExecute:()=>at,stringToAlpha:()=>Rt,stringToRgb:()=>Ft,touchCancelEvent:()=>d,touchEndEvent:()=>l,touchMoveEvent:()=>h,touchStartEvent:()=>c,tsParticles:()=>Ti,visibilityChangeEvent:()=>p});const i="generated",s="pointerdown",o="pointerup",n="pointerleave",a="pointerout",r="pointermove",c="touchstart",l="touchend",h="touchmove",d="touchcancel",u="resize",p="visibilitychange",f="tsParticles - Error";class m{constructor(t,e,i){if(this._updateFromAngle=(t,e)=>{this.x=Math.cos(t)*e,this.y=Math.sin(t)*e},!bt(t)&&t){this.x=t.x,this.y=t.y;const e=t;this.z=e.z?e.z:0}else{if(void 0===t||void 0===e)throw new Error(`${f} Vector3d not initialized correctly`);this.x=t,this.y=e,this.z=i??0}}static get origin(){return m.create(0,0,0)}get angle(){return Math.atan2(this.y,this.x)}set angle(t){this._updateFromAngle(t,this.length)}get length(){return Math.sqrt(this.getLengthSq())}set length(t){this._updateFromAngle(this.angle,t)}static clone(t){return m.create(t.x,t.y,t.z)}static create(t,e,i){return new m(t,e,i)}add(t){return m.create(this.x+t.x,this.y+t.y,this.z+t.z)}addTo(t){this.x+=t.x,this.y+=t.y,this.z+=t.z}copy(){return m.clone(this)}distanceTo(t){return this.sub(t).length}distanceToSq(t){return this.sub(t).getLengthSq()}div(t){return m.create(this.x/t,this.y/t,this.z/t)}divTo(t){this.x/=t,this.y/=t,this.z/=t}getLengthSq(){return this.x**2+this.y**2}mult(t){return m.create(this.x*t,this.y*t,this.z*t)}multTo(t){this.x*=t,this.y*=t,this.z*=t}normalize(){const t=this.length;0!=t&&this.multTo(1/t)}rotate(t){return m.create(this.x*Math.cos(t)-this.y*Math.sin(t),this.x*Math.sin(t)+this.y*Math.cos(t),0)}setTo(t){this.x=t.x,this.y=t.y;const e=t;this.z=e.z?e.z:0}sub(t){return m.create(this.x-t.x,this.y-t.y,this.z-t.z)}subFrom(t){this.x-=t.x,this.y-=t.y,this.z-=t.z}}class v extends m{constructor(t,e){super(t,e,0)}static get origin(){return v.create(0,0)}static clone(t){return v.create(t.x,t.y)}static create(t,e){return new v(t,e)}}let y=Math.random;const g=new Map;function w(t,e){g.get(t)||g.set(t,e)}function b(t){return g.get(t)||(t=>t)}function x(t=Math.random){y=t}function _(){return z(y(),0,1-1e-16)}function z(t,e,i){return Math.min(Math.max(t,e),i)}function M(t,e,i,s){return Math.floor((t*i+e*s)/(i+s))}function C(t){const e=k(t);let i=S(t);return e===i&&(i=0),_()*(e-i)+i}function P(t){return bt(t)?t:C(t)}function S(t){return bt(t)?t:t.min}function k(t){return bt(t)?t:t.max}function D(t,e){if(t===e||void 0===e&&bt(t))return t;const i=S(t),s=k(t);return void 0!==e?{min:Math.min(i,e),max:Math.max(s,e)}:D(i,s)}function O(t,e){const i=t.x-e.x,s=t.y-e.y;return{dx:i,dy:s,distance:Math.sqrt(i**2+s**2)}}function T(t,e){return O(t,e).distance}function I(t,e,i){if(bt(t))return t*Math.PI/180;switch(t){case"top":return.5*-Math.PI;case"top-right":return.25*-Math.PI;case"right":return 0;case"bottom-right":return.25*Math.PI;case"bottom":return.5*Math.PI;case"bottom-left":return.75*Math.PI;case"left":return Math.PI;case"top-left":return.75*-Math.PI;case"inside":return Math.atan2(i.y-e.y,i.x-e.x);case"outside":return Math.atan2(e.y-i.y,e.x-i.x);default:return _()*Math.PI*2}}function E(t){const e=v.origin;return e.length=1,e.angle=t,e}function R(t,e,i,s){return v.create(t.x*(i-s)/(i+s)+2*e.x*s/(i+s),t.y)}function F(t){return t.position&&void 0!==t.position.x&&void 0!==t.position.y?{x:t.position.x*t.size.width/100,y:t.position.y*t.size.height/100}:void 0}function L(t){return{x:(t.position?.x??100*_())*t.size.width/100,y:(t.position?.y??100*_())*t.size.height/100}}function A(t){const e={x:void 0!==t.position?.x?P(t.position.x):void 0,y:void 0!==t.position?.y?P(t.position.y):void 0};return L({size:t.size,position:e})}function B(t){return{x:t.position?.x??_()*t.size.width,y:t.position?.y??_()*t.size.height}}function V(t){const e={x:void 0!==t.position?.x?P(t.position.x):void 0,y:void 0!==t.position?.y?P(t.position.y):void 0};return B({size:t.size,position:e})}function U(t){return t?t.endsWith("%")?parseFloat(t)/100:parseFloat(t):1}const $={debug:console.debug,error:console.error,info:console.info,log:console.log,verbose:console.log,warning:console.warn};function q(t){$.debug=t.debug||$.debug,$.error=t.error||$.error,$.info=t.info||$.info,$.log=t.log||$.log,$.verbose=t.verbose||$.verbose,$.warning=t.warning||$.warning}function G(){return $}function H(t){const e={bounced:!1},{pSide:i,pOtherSide:s,rectSide:o,rectOtherSide:n,velocity:a,factor:r}=t;return s.minn.max||s.maxn.max||(i.max>=o.min&&i.max<=.5*(o.max+o.min)&&a>0||i.min<=o.max&&i.min>.5*(o.max+o.min)&&a<0)&&(e.velocity=a*-r,e.bounced=!0),e}function j(){return"undefined"==typeof window||!window||void 0===window.document||!window.document}function W(){return!j()&&"undefined"!=typeof matchMedia}function N(t){if(W())return matchMedia(t)}function X(t){if(!j()&&"undefined"!=typeof IntersectionObserver)return new IntersectionObserver(t)}function Y(t){if(!j()&&"undefined"!=typeof MutationObserver)return new MutationObserver(t)}function Z(t,e){return t===e||zt(e)&&e.indexOf(t)>-1}async function Q(t,e){try{await document.fonts.load(`${e??"400"} 36px '${t??"Verdana"}'`)}catch{}}function J(t){return Math.floor(_()*t.length)}function K(t,e,i=!0){return t[void 0!==e&&i?e%t.length:J(t)]}function tt(t,e,i,s,o){return et(it(t,s??0),e,i,o)}function et(t,e,i,s){let o=!0;return s&&"bottom"!==s||(o=t.topi.x),!o||s&&"right"!==s||(o=t.lefti.y),o}function it(t,e){return{bottom:t.y+e,left:t.x-e,right:t.x+e,top:t.y-e}}function st(t,...e){for(const i of e){if(null==i)continue;if(!_t(i)){t=i;continue}const e=Array.isArray(i);!e||!_t(t)&&t&&Array.isArray(t)?e||!_t(t)&&t&&!Array.isArray(t)||(t={}):t=[];for(const e in i){if("__proto__"===e)continue;const s=i[e],o=t;o[e]=_t(s)&&Array.isArray(s)?s.map((t=>st(o[e],t))):st(o[e],s)}}return t}function ot(t,e){return!!pt(e,(e=>e.enable&&Z(t,e.mode)))}function nt(t,e,i){dt(e,(e=>{const s=e.mode;e.enable&&Z(t,s)&&at(e,i)}))}function at(t,e){dt(t.selectors,(i=>{e(i,t)}))}function rt(t,e){if(e&&t)return pt(t,(t=>function(t,e){const i=dt(e,(e=>t.matches(e)));return zt(i)?i.some((t=>t)):i}(e,t.selectors)))}function ct(t){return{position:t.getPosition(),radius:t.getRadius(),mass:t.getMass(),velocity:t.velocity,factor:v.create(P(t.options.bounce.horizontal.value),P(t.options.bounce.vertical.value))}}function lt(t,e){const{x:i,y:s}=t.velocity.sub(e.velocity),[o,n]=[t.position,e.position],{dx:a,dy:r}=O(n,o);if(i*a+s*r<0)return;const c=-Math.atan2(r,a),l=t.mass,h=e.mass,d=t.velocity.rotate(c),u=e.velocity.rotate(c),p=R(d,u,l,h),f=R(u,d,l,h),m=p.rotate(-c),v=f.rotate(-c);t.velocity.x=m.x*t.factor.x,t.velocity.y=m.y*t.factor.y,e.velocity.x=v.x*e.factor.x,e.velocity.y=v.y*e.factor.y}function ht(t,e){const i=it(t.getPosition(),t.getRadius()),s=t.options.bounce,o=H({pSide:{min:i.left,max:i.right},pOtherSide:{min:i.top,max:i.bottom},rectSide:{min:e.left,max:e.right},rectOtherSide:{min:e.top,max:e.bottom},velocity:t.velocity.x,factor:P(s.horizontal.value)});o.bounced&&(void 0!==o.velocity&&(t.velocity.x=o.velocity),void 0!==o.position&&(t.position.x=o.position));const n=H({pSide:{min:i.top,max:i.bottom},pOtherSide:{min:i.left,max:i.right},rectSide:{min:e.top,max:e.bottom},rectOtherSide:{min:e.left,max:e.right},velocity:t.velocity.y,factor:P(s.vertical.value)});n.bounced&&(void 0!==n.velocity&&(t.velocity.y=n.velocity),void 0!==n.position&&(t.position.y=n.position))}function dt(t,e){return zt(t)?t.map(((t,i)=>e(t,i))):e(t,0)}function ut(t,e,i){return zt(t)?K(t,e,i):t}function pt(t,e){return zt(t)?t.find(((t,i)=>e(t,i))):e(t,0)?t:void 0}function ft(t,e){const i=t.value,s=t.animation,o={delayTime:1e3*P(s.delay),enable:s.enable,value:P(t.value)*e,max:k(i)*e,min:S(i)*e,loops:0,maxLoops:P(s.count),time:0};if(s.enable){switch(o.decay=1-P(s.decay),s.mode){case"increase":o.status="increasing";break;case"decrease":o.status="decreasing";break;case"random":o.status=_()>=.5?"increasing":"decreasing"}const t="auto"===s.mode;switch(s.startValue){case"min":o.value=o.min,t&&(o.status="increasing");break;case"max":o.value=o.max,t&&(o.status="decreasing");break;default:o.value=C(o),t&&(o.status=_()>=.5?"increasing":"decreasing")}}return o.initialValue=o.value,o}function mt(t,e){if(!("percent"===t.mode)){const{mode:e,...i}=t;return i}return"x"in t?{x:t.x/100*e.width,y:t.y/100*e.height}:{width:t.width/100*e.width,height:t.height/100*e.height}}function vt(t,e){return mt(t,e)}function yt(t,e){return mt(t,e)}function gt(t){return"boolean"==typeof t}function wt(t){return"string"==typeof t}function bt(t){return"number"==typeof t}function xt(t){return"function"==typeof t}function _t(t){return"object"==typeof t&&null!==t}function zt(t){return Array.isArray(t)}const Mt="random",Ct="mid",Pt=new Map;function St(t){Pt.set(t.key,t)}function kt(t){for(const[,e]of Pt)if(t.startsWith(e.stringPrefix))return e.parseString(t);const e=t.replace(/^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i,((t,e,i,s,o)=>e+e+i+i+s+s+(void 0!==o?o+o:""))),i=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(e);return i?{a:void 0!==i[4]?parseInt(i[4],16)/255:1,b:parseInt(i[3],16),g:parseInt(i[2],16),r:parseInt(i[1],16)}:void 0}function Dt(t,e,i=!0){if(!t)return;const s=wt(t)?{value:t}:t;if(wt(s.value))return Ot(s.value,e,i);if(zt(s.value))return Dt({value:K(s.value,e,i)});for(const[,t]of Pt){const e=t.handleRangeColor(s);if(e)return e}}function Ot(t,e,i=!0){if(!t)return;const s=wt(t)?{value:t}:t;if(wt(s.value))return s.value===Mt?Bt():Ft(s.value);if(zt(s.value))return Ot({value:K(s.value,e,i)});for(const[,t]of Pt){const e=t.handleColor(s);if(e)return e}}function Tt(t,e,i=!0){const s=Ot(t,e,i);return s?Et(s):void 0}function It(t,e,i=!0){const s=Dt(t,e,i);return s?Et(s):void 0}function Et(t){const e=t.r/255,i=t.g/255,s=t.b/255,o=Math.max(e,i,s),n=Math.min(e,i,s),a={h:0,l:.5*(o+n),s:0};return o!==n&&(a.s=a.l<.5?(o-n)/(o+n):(o-n)/(2-o-n),a.h=e===o?(i-s)/(o-n):a.h=i===o?2+(s-e)/(o-n):4+(e-i)/(o-n)),a.l*=100,a.s*=100,a.h*=60,a.h<0&&(a.h+=360),a.h>=360&&(a.h-=360),a}function Rt(t){return kt(t)?.a}function Ft(t){return kt(t)}function Lt(t){const e=(t.h%360+360)%360,i=Math.max(0,Math.min(100,t.s)),s=e/360,o=i/100,n=Math.max(0,Math.min(100,t.l))/100;if(0===i){const t=Math.round(255*n);return{r:t,g:t,b:t}}const a=(t,e,i)=>(i<0&&(i+=1),i>1&&(i-=1),6*i<1?t+6*(e-t)*i:2*i<1?e:3*i<2?t+(e-t)*(2/3-i)*6:t),r=n<.5?n*(1+o):n+o-n*o,c=2*n-r,l=Math.min(255,255*a(c,r,s+1/3)),h=Math.min(255,255*a(c,r,s)),d=Math.min(255,255*a(c,r,s-1/3));return{r:Math.round(l),g:Math.round(h),b:Math.round(d)}}function At(t){const e=Lt(t);return{a:t.a,b:e.b,g:e.g,r:e.r}}function Bt(t){const e=t??0;return{b:Math.floor(C(D(e,256))),g:Math.floor(C(D(e,256))),r:Math.floor(C(D(e,256)))}}function Vt(t,e){return`rgba(${t.r}, ${t.g}, ${t.b}, ${e??1})`}function Ut(t,e){return`hsla(${t.h}, ${t.s}%, ${t.l}%, ${e??1})`}function $t(t,e,i,s){let o=t,n=e;return void 0===o.r&&(o=Lt(t)),void 0===n.r&&(n=Lt(e)),{b:M(o.b,n.b,i,s),g:M(o.g,n.g,i,s),r:M(o.r,n.r,i,s)}}function qt(t,e,i){if(i===Mt)return Bt();if(i!==Ct)return i;{const i=t.getFillColor()??t.getStrokeColor(),s=e?.getFillColor()??e?.getStrokeColor();if(i&&s&&e)return $t(i,s,t.getRadius(),e.getRadius());{const t=i??s;if(t)return Lt(t)}}}function Gt(t,e,i){const s=wt(t)?t:t.value;return s===Mt?i?Dt({value:s}):e?Mt:Ct:s===Ct?Ct:Dt({value:s})}function Ht(t){return void 0!==t?{h:t.h.value,s:t.s.value,l:t.l.value}:void 0}function jt(t,e,i){const s={h:{enable:!1,value:t.h},s:{enable:!1,value:t.s},l:{enable:!1,value:t.l}};return e&&(Wt(s.h,e.h,i),Wt(s.s,e.s,i),Wt(s.l,e.l,i)),s}function Wt(t,e,i){t.enable=e.enable,t.enable?(t.velocity=P(e.speed)/100*i,t.decay=1-P(e.decay),t.status="increasing",t.loops=0,t.maxLoops=P(e.count),t.time=0,t.delayTime=1e3*P(e.delay),e.sync||(t.velocity*=_(),t.value*=_()),t.initialValue=t.value):t.velocity=0}function Nt(t,e,i){t.beginPath(),t.moveTo(e.x,e.y),t.lineTo(i.x,i.y),t.closePath()}function Xt(t,e,i){t.fillStyle=i??"rgba(0,0,0,0)",t.fillRect(0,0,e.width,e.height)}function Yt(t,e,i,s){i&&(t.globalAlpha=s,t.drawImage(i,0,0,e.width,e.height),t.globalAlpha=1)}function Zt(t,e){t.clearRect(0,0,e.width,e.height)}function Qt(t){const{container:e,context:i,particle:s,delta:o,colorStyles:n,backgroundMask:a,composite:r,radius:c,opacity:l,shadow:h,transform:d}=t,u=s.getPosition(),p=s.rotation+(s.pathRotation?s.velocity.angle:0),f=Math.sin(p),m=Math.cos(p),v={a:m*(d.a??1),b:f*(d.b??1),c:-f*(d.c??1),d:m*(d.d??1)};i.setTransform(v.a,v.b,v.c,v.d,u.x,u.y),a&&(i.globalCompositeOperation=r);const y=s.shadowColor;h.enable&&y&&(i.shadowBlur=h.blur,i.shadowColor=Vt(y),i.shadowOffsetX=h.offset.x,i.shadowOffsetY=h.offset.y),n.fill&&(i.fillStyle=n.fill);const g=s.strokeWidth??0;i.lineWidth=g,n.stroke&&(i.strokeStyle=n.stroke);const w={container:e,context:i,particle:s,radius:c,opacity:l,delta:o,transformData:v};i.beginPath(),Kt(w),s.shapeClose&&i.closePath(),g>0&&i.stroke(),s.shapeFill&&i.fill(),te(w),Jt(w),i.globalCompositeOperation="source-over",i.setTransform(1,0,0,1,0,0)}function Jt(t){const{container:e,context:i,particle:s,radius:o,opacity:n,delta:a,transformData:r}=t;if(!s.effect)return;const c=e.effectDrawers.get(s.effect);c&&c.draw({context:i,particle:s,radius:o,opacity:n,delta:a,pixelRatio:e.retina.pixelRatio,transformData:{...r}})}function Kt(t){const{container:e,context:i,particle:s,radius:o,opacity:n,delta:a,transformData:r}=t;if(!s.shape)return;const c=e.shapeDrawers.get(s.shape);c&&c.draw({context:i,particle:s,radius:o,opacity:n,delta:a,pixelRatio:e.retina.pixelRatio,transformData:{...r}})}function te(t){const{container:e,context:i,particle:s,radius:o,opacity:n,delta:a,transformData:r}=t;if(!s.shape)return;const c=e.shapeDrawers.get(s.shape);c&&c.afterDraw&&c.afterDraw({context:i,particle:s,radius:o,opacity:n,delta:a,pixelRatio:e.retina.pixelRatio,transformData:{...r}})}function ee(t,e,i){e.draw&&e.draw(t,i)}function ie(t,e,i,s){e.drawParticle&&e.drawParticle(t,i,s)}function se(t,e,i){return{h:t.h,s:t.s,l:t.l+("darken"===e?-1:1)*i}}function oe(t,e,i){const s=e[i];void 0!==s&&(t[i]=(t[i]??1)*s)}class ne{constructor(t){this.container=t,this._applyPostDrawUpdaters=t=>{for(const e of this._postDrawUpdaters)e.afterDraw&&e.afterDraw(t)},this._applyPreDrawUpdaters=(t,e,i,s,o,n)=>{for(const a of this._preDrawUpdaters){if(a.getColorStyles){const{fill:n,stroke:r}=a.getColorStyles(e,t,i,s);n&&(o.fill=n),r&&(o.stroke=r)}if(a.getTransformValues){const t=a.getTransformValues(e);for(const e in t)oe(n,t,e)}a.beforeDraw&&a.beforeDraw(e)}},this._applyResizePlugins=()=>{for(const t of this._resizePlugins)t.resize&&t.resize()},this._getPluginParticleColors=t=>{let e,i;for(const s of this._colorPlugins)if(!e&&s.particleFillColor&&(e=It(s.particleFillColor(t))),!i&&s.particleStrokeColor&&(i=It(s.particleStrokeColor(t))),e&&i)break;return[e,i]},this._initCover=()=>{const t=this.container.actualOptions.backgroundMask.cover,e=Dt(t.color);if(e){const i={...e,a:t.opacity};this._coverColorStyle=Vt(i,i.a)}},this._initStyle=()=>{const t=this.element,e=this.container.actualOptions;if(t){this._fullScreen?(this._originalStyle=st({},t.style),this._setFullScreenStyle()):this._resetOriginalStyle();for(const i in e.style){if(!i||!e.style)continue;const s=e.style[i];s&&t.style.setProperty(i,s,"important")}}},this._initTrail=async()=>{const t=this.container.actualOptions,e=t.particles.move.trail,i=e.fill;if(e.enable)if(i.color){const e=Dt(i.color);if(!e)return;const s=t.particles.move.trail;this._trailFill={color:{...e},opacity:1/s.length}}else await new Promise(((t,s)=>{if(!i.image)return;const o=document.createElement("img");o.addEventListener("load",(()=>{this._trailFill={image:o,opacity:1/e.length},t()})),o.addEventListener("error",(t=>{s(t.error)})),o.src=i.image}))},this._paintBase=t=>{this.draw((e=>Xt(e,this.size,t)))},this._paintImage=(t,e)=>{this.draw((i=>Yt(i,this.size,t,e)))},this._repairStyle=()=>{const t=this.element;t&&(this._safeMutationObserver((t=>t.disconnect())),this._initStyle(),this.initBackground(),this._safeMutationObserver((e=>e.observe(t,{attributes:!0}))))},this._resetOriginalStyle=()=>{const t=this.element,e=this._originalStyle;if(!t||!e)return;const i=t.style;i.position=e.position,i.zIndex=e.zIndex,i.top=e.top,i.left=e.left,i.width=e.width,i.height=e.height},this._safeMutationObserver=t=>{this._mutationObserver&&t(this._mutationObserver)},this._setFullScreenStyle=()=>{const t=this.element;if(!t)return;const e="important",i=t.style;i.setProperty("position","fixed",e),i.setProperty("z-index",this.container.actualOptions.fullScreen.zIndex.toString(10),e),i.setProperty("top","0",e),i.setProperty("left","0",e),i.setProperty("width","100%",e),i.setProperty("height","100%",e)},this.size={height:0,width:0},this._context=null,this._generated=!1,this._preDrawUpdaters=[],this._postDrawUpdaters=[],this._resizePlugins=[],this._colorPlugins=[]}get _fullScreen(){return this.container.actualOptions.fullScreen.enable}clear(){const t=this.container.actualOptions,e=t.particles.move.trail,i=this._trailFill;t.backgroundMask.enable?this.paint():e.enable&&e.length>0&&i?i.color?this._paintBase(Vt(i.color,i.opacity)):i.image&&this._paintImage(i.image,i.opacity):t.clear&&this.draw((t=>{Zt(t,this.size)}))}destroy(){if(this.stop(),this._generated){const t=this.element;t&&t.remove()}else this._resetOriginalStyle();this._preDrawUpdaters=[],this._postDrawUpdaters=[],this._resizePlugins=[],this._colorPlugins=[]}draw(t){const e=this._context;if(e)return t(e)}drawParticle(t,e){if(t.spawning||t.destroyed)return;const i=t.getRadius();if(i<=0)return;const s=t.getFillColor(),o=t.getStrokeColor()??s;let[n,a]=this._getPluginParticleColors(t);n||(n=s),a||(a=o),(n||a)&&this.draw((s=>{const o=this.container,r=o.actualOptions,c=t.options.zIndex,l=(1-t.zIndexFactor)**c.opacityRate,h=t.bubble.opacity??t.opacity?.value??1,d=h*l,u=(t.strokeOpacity??h)*l,p={},f={fill:n?Ut(n,d):void 0};f.stroke=a?Ut(a,u):f.fill,this._applyPreDrawUpdaters(s,t,i,d,f,p),Qt({container:o,context:s,particle:t,delta:e,colorStyles:f,backgroundMask:r.backgroundMask.enable,composite:r.backgroundMask.composite,radius:i*(1-t.zIndexFactor)**c.sizeRate,opacity:d,shadow:t.options.shadow,transform:p}),this._applyPostDrawUpdaters(t)}))}drawParticlePlugin(t,e,i){this.draw((s=>ie(s,t,e,i)))}drawPlugin(t,e){this.draw((i=>ee(i,t,e)))}async init(){this._safeMutationObserver((t=>t.disconnect())),this._mutationObserver=Y((t=>{for(const e of t)"attributes"===e.type&&"style"===e.attributeName&&this._repairStyle()})),this.resize(),this._initStyle(),this._initCover();try{await this._initTrail()}catch(t){G().error(t)}this.initBackground(),this._safeMutationObserver((t=>{this.element&&t.observe(this.element,{attributes:!0})})),this.initUpdaters(),this.initPlugins(),this.paint()}initBackground(){const t=this.container.actualOptions.background,e=this.element;if(!e)return;const i=e.style;if(i){if(t.color){const e=Dt(t.color);i.backgroundColor=e?Vt(e,t.opacity):""}else i.backgroundColor="";i.backgroundImage=t.image||"",i.backgroundPosition=t.position||"",i.backgroundRepeat=t.repeat||"",i.backgroundSize=t.size||""}}initPlugins(){this._resizePlugins=[];for(const[,t]of this.container.plugins)t.resize&&this._resizePlugins.push(t),(t.particleFillColor||t.particleStrokeColor)&&this._colorPlugins.push(t)}initUpdaters(){this._preDrawUpdaters=[],this._postDrawUpdaters=[];for(const t of this.container.particles.updaters)t.afterDraw&&this._postDrawUpdaters.push(t),(t.getColorStyles||t.getTransformValues||t.beforeDraw)&&this._preDrawUpdaters.push(t)}loadCanvas(t){this._generated&&this.element&&this.element.remove(),this._generated=t.dataset&&i in t.dataset?"true"===t.dataset[i]:this._generated,this.element=t,this.element.ariaHidden="true",this._originalStyle=st({},this.element.style),this.size.height=t.offsetHeight,this.size.width=t.offsetWidth,this._context=this.element.getContext("2d"),this._safeMutationObserver((t=>{this.element&&t.observe(this.element,{attributes:!0})})),this.container.retina.init(),this.initBackground()}paint(){const t=this.container.actualOptions;this.draw((e=>{t.backgroundMask.enable&&t.backgroundMask.cover?(Zt(e,this.size),this._paintBase(this._coverColorStyle)):this._paintBase()}))}resize(){if(!this.element)return!1;const t=this.container,e=t.retina.pixelRatio,i=t.canvas.size,s=this.element.offsetWidth*e,o=this.element.offsetHeight*e;if(o===i.height&&s===i.width&&o===this.element.height&&s===this.element.width)return!1;const n={...i};return this.element.width=i.width=this.element.offsetWidth*e,this.element.height=i.height=this.element.offsetHeight*e,this.container.started&&t.particles.setResizeFactor({width:i.width/n.width,height:i.height/n.height}),!0}stop(){this._safeMutationObserver((t=>t.disconnect())),this._mutationObserver=void 0,this.draw((t=>Zt(t,this.size)))}async windowResize(){if(!this.element||!this.resize())return;const t=this.container,e=t.updateActualOptions();t.particles.setDensity(),this._applyResizePlugins(),e&&await t.refresh()}}function ae(t,e,i,s,o){if(s){let s={passive:!0};gt(o)?s.capture=o:void 0!==o&&(s=o),t.addEventListener(e,i,s)}else{const s=o;t.removeEventListener(e,i,s)}}class re{constructor(t){this.container=t,this._doMouseTouchClick=t=>{const e=this.container,i=e.actualOptions;if(this._canPush){const t=e.interactivity.mouse,s=t.position;if(!s)return;t.clickPosition={...s},t.clickTime=(new Date).getTime();dt(i.interactivity.events.onClick.mode,(t=>this.container.handleClickMode(t)))}"touchend"===t.type&&setTimeout((()=>this._mouseTouchFinish()),500)},this._handleThemeChange=t=>{const e=t,i=this.container,s=i.options,o=s.defaultThemes,n=e.matches?o.dark:o.light,a=s.themes.find((t=>t.name===n));a&&a.default.auto&&i.loadTheme(n)},this._handleVisibilityChange=()=>{const t=this.container,e=t.actualOptions;this._mouseTouchFinish(),e.pauseOnBlur&&(document&&document.hidden?(t.pageHidden=!0,t.pause()):(t.pageHidden=!1,t.getAnimationStatus()?t.play(!0):t.draw(!0)))},this._handleWindowResize=async()=>{this._resizeTimeout&&(clearTimeout(this._resizeTimeout),delete this._resizeTimeout),this._resizeTimeout=setTimeout((async()=>{const t=this.container.canvas;t&&await t.windowResize()}),1e3*this.container.actualOptions.interactivity.events.resize.delay)},this._manageInteractivityListeners=(t,e)=>{const i=this._handlers,n=this.container,a=n.actualOptions,u=n.interactivity.element;if(!u)return;const p=u,f=n.canvas.element;f&&(f.style.pointerEvents=p===f?"initial":"none"),(a.interactivity.events.onHover.enable||a.interactivity.events.onClick.enable)&&(ae(u,r,i.mouseMove,e),ae(u,c,i.touchStart,e),ae(u,h,i.touchMove,e),a.interactivity.events.onClick.enable?(ae(u,l,i.touchEndClick,e),ae(u,o,i.mouseUp,e),ae(u,s,i.mouseDown,e)):ae(u,l,i.touchEnd,e),ae(u,t,i.mouseLeave,e),ae(u,d,i.touchCancel,e))},this._manageListeners=t=>{const e=this._handlers,i=this.container,s=i.actualOptions.interactivity.detectsOn,o=i.canvas.element;let r=n;"window"===s?(i.interactivity.element=window,r=a):i.interactivity.element="parent"===s&&o?o.parentElement??o.parentNode:o,this._manageMediaMatch(t),this._manageResize(t),this._manageInteractivityListeners(r,t),document&&ae(document,p,e.visibilityChange,t,!1)},this._manageMediaMatch=t=>{const e=this._handlers,i=N("(prefers-color-scheme: dark)");i&&(void 0===i.addEventListener?void 0!==i.addListener&&(t?i.addListener(e.oldThemeChange):i.removeListener(e.oldThemeChange)):ae(i,"change",e.themeChange,t))},this._manageResize=t=>{const e=this._handlers,i=this.container;if(!i.actualOptions.interactivity.events.resize)return;if("undefined"==typeof ResizeObserver)return void ae(window,u,e.resize,t);const s=i.canvas.element;this._resizeObserver&&!t?(s&&this._resizeObserver.unobserve(s),this._resizeObserver.disconnect(),delete this._resizeObserver):!this._resizeObserver&&t&&s&&(this._resizeObserver=new ResizeObserver((async t=>{t.find((t=>t.target===s))&&await this._handleWindowResize()})),this._resizeObserver.observe(s))},this._mouseDown=()=>{const{interactivity:t}=this.container;if(!t)return;const{mouse:e}=t;e.clicking=!0,e.downPosition=e.position},this._mouseTouchClick=t=>{const e=this.container,i=e.actualOptions,{mouse:s}=e.interactivity;s.inside=!0;let o=!1;const n=s.position;if(n&&i.interactivity.events.onClick.enable){for(const[,t]of e.plugins)if(t.clickPositionValid&&(o=t.clickPositionValid(n),o))break;o||this._doMouseTouchClick(t),s.clicking=!1}},this._mouseTouchFinish=()=>{const t=this.container.interactivity;if(!t)return;const e=t.mouse;delete e.position,delete e.clickPosition,delete e.downPosition,t.status=n,e.inside=!1,e.clicking=!1},this._mouseTouchMove=t=>{const e=this.container,i=e.actualOptions,s=e.interactivity,o=e.canvas.element;if(!s||!s.element)return;let n;if(s.mouse.inside=!0,t.type.startsWith("pointer")){this._canPush=!0;const e=t;if(s.element===window){if(o){const t=o.getBoundingClientRect();n={x:e.clientX-t.left,y:e.clientY-t.top}}}else if("parent"===i.interactivity.detectsOn){const t=e.target,i=e.currentTarget;if(t&&i&&o){const s=t.getBoundingClientRect(),a=i.getBoundingClientRect(),r=o.getBoundingClientRect();n={x:e.offsetX+2*s.left-(a.left+r.left),y:e.offsetY+2*s.top-(a.top+r.top)}}else n={x:e.offsetX??e.clientX,y:e.offsetY??e.clientY}}else e.target===o&&(n={x:e.offsetX??e.clientX,y:e.offsetY??e.clientY})}else if(this._canPush="touchmove"!==t.type,o){const e=t,i=e.touches[e.touches.length-1],s=o.getBoundingClientRect();n={x:i.clientX-(s.left??0),y:i.clientY-(s.top??0)}}const a=e.retina.pixelRatio;n&&(n.x*=a,n.y*=a),s.mouse.position=n,s.status=r},this._touchEnd=t=>{const e=t,i=Array.from(e.changedTouches);for(const t of i)this._touches.delete(t.identifier);this._mouseTouchFinish()},this._touchEndClick=t=>{const e=t,i=Array.from(e.changedTouches);for(const t of i)this._touches.delete(t.identifier);this._mouseTouchClick(t)},this._touchStart=t=>{const e=t,i=Array.from(e.changedTouches);for(const t of i)this._touches.set(t.identifier,performance.now());this._mouseTouchMove(t)},this._canPush=!0,this._touches=new Map,this._handlers={mouseDown:()=>this._mouseDown(),mouseLeave:()=>this._mouseTouchFinish(),mouseMove:t=>this._mouseTouchMove(t),mouseUp:t=>this._mouseTouchClick(t),touchStart:t=>this._touchStart(t),touchMove:t=>this._mouseTouchMove(t),touchEnd:t=>this._touchEnd(t),touchCancel:t=>this._touchEnd(t),touchEndClick:t=>this._touchEndClick(t),visibilityChange:()=>this._handleVisibilityChange(),themeChange:t=>this._handleThemeChange(t),oldThemeChange:t=>this._handleThemeChange(t),resize:()=>{this._handleWindowResize()}}}addListeners(){this._manageListeners(!0)}removeListeners(){this._manageListeners(!1)}}class ce{constructor(){this.value=""}static create(t,e){const i=new ce;return i.load(t),void 0!==e&&(wt(e)||zt(e)?i.load({value:e}):i.load(e)),i}load(t){void 0!==t?.value&&(this.value=t.value)}}class le{constructor(){this.color=new ce,this.color.value="",this.image="",this.position="",this.repeat="",this.size="",this.opacity=1}load(t){t&&(void 0!==t.color&&(this.color=ce.create(this.color,t.color)),void 0!==t.image&&(this.image=t.image),void 0!==t.position&&(this.position=t.position),void 0!==t.repeat&&(this.repeat=t.repeat),void 0!==t.size&&(this.size=t.size),void 0!==t.opacity&&(this.opacity=t.opacity))}}class he{constructor(){this.color=new ce,this.color.value="#fff",this.opacity=1}load(t){t&&(void 0!==t.color&&(this.color=ce.create(this.color,t.color)),void 0!==t.opacity&&(this.opacity=t.opacity))}}class de{constructor(){this.composite="destination-out",this.cover=new he,this.enable=!1}load(t){if(t){if(void 0!==t.composite&&(this.composite=t.composite),void 0!==t.cover){const e=t.cover,i=wt(t.cover)?{color:t.cover}:t.cover;this.cover.load(void 0!==e.color?e:{color:i})}void 0!==t.enable&&(this.enable=t.enable)}}}class ue{constructor(){this.enable=!0,this.zIndex=0}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.zIndex&&(this.zIndex=t.zIndex))}}class pe{constructor(){this.enable=!1,this.mode=[]}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.mode&&(this.mode=t.mode))}}class fe{constructor(){this.selectors=[],this.enable=!1,this.mode=[],this.type="circle"}load(t){t&&(void 0!==t.selectors&&(this.selectors=t.selectors),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.mode&&(this.mode=t.mode),void 0!==t.type&&(this.type=t.type))}}class me{constructor(){this.enable=!1,this.force=2,this.smooth=10}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.force&&(this.force=t.force),void 0!==t.smooth&&(this.smooth=t.smooth))}}class ve{constructor(){this.enable=!1,this.mode=[],this.parallax=new me}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.mode&&(this.mode=t.mode),this.parallax.load(t.parallax))}}class ye{constructor(){this.delay=.5,this.enable=!0}load(t){void 0!==t&&(void 0!==t.delay&&(this.delay=t.delay),void 0!==t.enable&&(this.enable=t.enable))}}class ge{constructor(){this.onClick=new pe,this.onDiv=new fe,this.onHover=new ve,this.resize=new ye}load(t){if(!t)return;this.onClick.load(t.onClick);const e=t.onDiv;void 0!==e&&(this.onDiv=dt(e,(t=>{const e=new fe;return e.load(t),e}))),this.onHover.load(t.onHover),this.resize.load(t.resize)}}class we{constructor(t,e){this._engine=t,this._container=e}load(t){if(!t)return;if(!this._container)return;const e=this._engine.interactors.get(this._container);if(e)for(const i of e)i.loadModeOptions&&i.loadModeOptions(this,t)}}class be{constructor(t,e){this.detectsOn="window",this.events=new ge,this.modes=new we(t,e)}load(t){if(!t)return;const e=t.detectsOn;void 0!==e&&(this.detectsOn=e),this.events.load(t.events),this.modes.load(t.modes)}}class xe{load(t){t&&(t.position&&(this.position={x:t.position.x??50,y:t.position.y??50,mode:t.position.mode??"percent"}),t.options&&(this.options=st({},t.options)))}}class _e{constructor(){this.maxWidth=1/0,this.options={},this.mode="canvas"}load(t){t&&(void 0!==t.maxWidth&&(this.maxWidth=t.maxWidth),void 0!==t.mode&&("screen"===t.mode?this.mode="screen":this.mode="canvas"),void 0!==t.options&&(this.options=st({},t.options)))}}class ze{constructor(){this.auto=!1,this.mode="any",this.value=!1}load(t){t&&(void 0!==t.auto&&(this.auto=t.auto),void 0!==t.mode&&(this.mode=t.mode),void 0!==t.value&&(this.value=t.value))}}class Me{constructor(){this.name="",this.default=new ze}load(t){t&&(void 0!==t.name&&(this.name=t.name),this.default.load(t.default),void 0!==t.options&&(this.options=st({},t.options)))}}class Ce{constructor(){this.count=0,this.enable=!1,this.speed=1,this.decay=0,this.delay=0,this.sync=!1}load(t){t&&(void 0!==t.count&&(this.count=D(t.count)),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.speed&&(this.speed=D(t.speed)),void 0!==t.decay&&(this.decay=D(t.decay)),void 0!==t.delay&&(this.delay=D(t.delay)),void 0!==t.sync&&(this.sync=t.sync))}}class Pe extends Ce{constructor(){super(),this.mode="auto",this.startValue="random"}load(t){super.load(t),t&&(void 0!==t.mode&&(this.mode=t.mode),void 0!==t.startValue&&(this.startValue=t.startValue))}}class Se extends Ce{constructor(){super(),this.offset=0,this.sync=!0}load(t){super.load(t),t&&void 0!==t.offset&&(this.offset=D(t.offset))}}class ke{constructor(){this.h=new Se,this.s=new Se,this.l=new Se}load(t){t&&(this.h.load(t.h),this.s.load(t.s),this.l.load(t.l))}}class De extends ce{constructor(){super(),this.animation=new ke}static create(t,e){const i=new De;return i.load(t),void 0!==e&&(wt(e)||zt(e)?i.load({value:e}):i.load(e)),i}load(t){if(super.load(t),!t)return;const e=t.animation;void 0!==e&&(void 0!==e.enable?this.animation.h.load(e):this.animation.load(t.animation))}}class Oe{constructor(){this.speed=2}load(t){t&&void 0!==t.speed&&(this.speed=t.speed)}}class Te{constructor(){this.enable=!0,this.retries=0}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.retries&&(this.retries=t.retries))}}class Ie{constructor(){this.value=0}load(t){t&&void 0!==t.value&&(this.value=D(t.value))}}class Ee extends Ie{constructor(){super(),this.animation=new Ce}load(t){if(super.load(t),!t)return;const e=t.animation;void 0!==e&&this.animation.load(e)}}class Re extends Ee{constructor(){super(),this.animation=new Pe}load(t){super.load(t)}}class Fe extends Ie{constructor(){super(),this.value=1}}class Le{constructor(){this.horizontal=new Fe,this.vertical=new Fe}load(t){t&&(this.horizontal.load(t.horizontal),this.vertical.load(t.vertical))}}class Ae{constructor(){this.absorb=new Oe,this.bounce=new Le,this.enable=!1,this.maxSpeed=50,this.mode="bounce",this.overlap=new Te}load(t){t&&(this.absorb.load(t.absorb),this.bounce.load(t.bounce),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.maxSpeed&&(this.maxSpeed=D(t.maxSpeed)),void 0!==t.mode&&(this.mode=t.mode),this.overlap.load(t.overlap))}}class Be{constructor(){this.close=!0,this.fill=!0,this.options={},this.type=[]}load(t){if(!t)return;const e=t.options;if(void 0!==e)for(const t in e){const i=e[t];i&&(this.options[t]=st(this.options[t]??{},i))}void 0!==t.close&&(this.close=t.close),void 0!==t.fill&&(this.fill=t.fill),void 0!==t.type&&(this.type=t.type)}}class Ve{constructor(){this.offset=0,this.value=90}load(t){t&&(void 0!==t.offset&&(this.offset=D(t.offset)),void 0!==t.value&&(this.value=D(t.value)))}}class Ue{constructor(){this.distance=200,this.enable=!1,this.rotate={x:3e3,y:3e3}}load(t){if(t&&(void 0!==t.distance&&(this.distance=D(t.distance)),void 0!==t.enable&&(this.enable=t.enable),t.rotate)){const e=t.rotate.x;void 0!==e&&(this.rotate.x=e);const i=t.rotate.y;void 0!==i&&(this.rotate.y=i)}}}class $e{constructor(){this.x=50,this.y=50,this.mode="percent",this.radius=0}load(t){t&&(void 0!==t.x&&(this.x=t.x),void 0!==t.y&&(this.y=t.y),void 0!==t.mode&&(this.mode=t.mode),void 0!==t.radius&&(this.radius=t.radius))}}class qe{constructor(){this.acceleration=9.81,this.enable=!1,this.inverse=!1,this.maxSpeed=50}load(t){t&&(void 0!==t.acceleration&&(this.acceleration=D(t.acceleration)),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.inverse&&(this.inverse=t.inverse),void 0!==t.maxSpeed&&(this.maxSpeed=D(t.maxSpeed)))}}class Ge{constructor(){this.clamp=!0,this.delay=new Ie,this.enable=!1,this.options={}}load(t){t&&(void 0!==t.clamp&&(this.clamp=t.clamp),this.delay.load(t.delay),void 0!==t.enable&&(this.enable=t.enable),this.generator=t.generator,t.options&&(this.options=st(this.options,t.options)))}}class He{load(t){t&&(void 0!==t.color&&(this.color=ce.create(this.color,t.color)),void 0!==t.image&&(this.image=t.image))}}class je{constructor(){this.enable=!1,this.length=10,this.fill=new He}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.fill&&this.fill.load(t.fill),void 0!==t.length&&(this.length=t.length))}}class We{constructor(){this.default="out"}load(t){t&&(void 0!==t.default&&(this.default=t.default),this.bottom=t.bottom??t.default,this.left=t.left??t.default,this.right=t.right??t.default,this.top=t.top??t.default)}}class Ne{constructor(){this.acceleration=0,this.enable=!1}load(t){t&&(void 0!==t.acceleration&&(this.acceleration=D(t.acceleration)),void 0!==t.enable&&(this.enable=t.enable),t.position&&(this.position=st({},t.position)))}}class Xe{constructor(){this.angle=new Ve,this.attract=new Ue,this.center=new $e,this.decay=0,this.distance={},this.direction="none",this.drift=0,this.enable=!1,this.gravity=new qe,this.path=new Ge,this.outModes=new We,this.random=!1,this.size=!1,this.speed=2,this.spin=new Ne,this.straight=!1,this.trail=new je,this.vibrate=!1,this.warp=!1}load(t){if(!t)return;this.angle.load(bt(t.angle)?{value:t.angle}:t.angle),this.attract.load(t.attract),this.center.load(t.center),void 0!==t.decay&&(this.decay=D(t.decay)),void 0!==t.direction&&(this.direction=t.direction),void 0!==t.distance&&(this.distance=bt(t.distance)?{horizontal:t.distance,vertical:t.distance}:{...t.distance}),void 0!==t.drift&&(this.drift=D(t.drift)),void 0!==t.enable&&(this.enable=t.enable),this.gravity.load(t.gravity);const e=t.outModes;void 0!==e&&(_t(e)?this.outModes.load(e):this.outModes.load({default:e})),this.path.load(t.path),void 0!==t.random&&(this.random=t.random),void 0!==t.size&&(this.size=t.size),void 0!==t.speed&&(this.speed=D(t.speed)),this.spin.load(t.spin),void 0!==t.straight&&(this.straight=t.straight),this.trail.load(t.trail),void 0!==t.vibrate&&(this.vibrate=t.vibrate),void 0!==t.warp&&(this.warp=t.warp)}}class Ye extends Pe{constructor(){super(),this.destroy="none",this.speed=2}load(t){super.load(t),t&&void 0!==t.destroy&&(this.destroy=t.destroy)}}class Ze extends Re{constructor(){super(),this.animation=new Ye,this.value=1}load(t){if(!t)return;super.load(t);const e=t.animation;void 0!==e&&this.animation.load(e)}}class Qe{constructor(){this.enable=!1,this.width=1920,this.height=1080}load(t){if(!t)return;void 0!==t.enable&&(this.enable=t.enable);const e=t.width;void 0!==e&&(this.width=e);const i=t.height;void 0!==i&&(this.height=i)}}class Je{constructor(){this.mode="delete",this.value=0}load(t){t&&(void 0!==t.mode&&(this.mode=t.mode),void 0!==t.value&&(this.value=t.value))}}class Ke{constructor(){this.density=new Qe,this.limit=new Je,this.value=0}load(t){t&&(this.density.load(t.density),this.limit.load(t.limit),void 0!==t.value&&(this.value=t.value))}}class ti{constructor(){this.blur=0,this.color=new ce,this.enable=!1,this.offset={x:0,y:0},this.color.value="#000"}load(t){t&&(void 0!==t.blur&&(this.blur=t.blur),this.color=ce.create(this.color,t.color),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.offset&&(void 0!==t.offset.x&&(this.offset.x=t.offset.x),void 0!==t.offset.y&&(this.offset.y=t.offset.y)))}}class ei{constructor(){this.close=!0,this.fill=!0,this.options={},this.type="circle"}load(t){if(!t)return;const e=t.options;if(void 0!==e)for(const t in e){const i=e[t];i&&(this.options[t]=st(this.options[t]??{},i))}void 0!==t.close&&(this.close=t.close),void 0!==t.fill&&(this.fill=t.fill),void 0!==t.type&&(this.type=t.type)}}class ii extends Pe{constructor(){super(),this.destroy="none",this.speed=5}load(t){super.load(t),t&&void 0!==t.destroy&&(this.destroy=t.destroy)}}class si extends Re{constructor(){super(),this.animation=new ii,this.value=3}load(t){if(super.load(t),!t)return;const e=t.animation;void 0!==e&&this.animation.load(e)}}class oi{constructor(){this.width=0}load(t){t&&(void 0!==t.color&&(this.color=De.create(this.color,t.color)),void 0!==t.width&&(this.width=D(t.width)),void 0!==t.opacity&&(this.opacity=D(t.opacity)))}}class ni extends Ie{constructor(){super(),this.opacityRate=1,this.sizeRate=1,this.velocityRate=1}load(t){super.load(t),t&&(void 0!==t.opacityRate&&(this.opacityRate=t.opacityRate),void 0!==t.sizeRate&&(this.sizeRate=t.sizeRate),void 0!==t.velocityRate&&(this.velocityRate=t.velocityRate))}}class ai{constructor(t,e){this._engine=t,this._container=e,this.bounce=new Le,this.collisions=new Ae,this.color=new De,this.color.value="#fff",this.effect=new Be,this.groups={},this.move=new Xe,this.number=new Ke,this.opacity=new Ze,this.reduceDuplicates=!1,this.shadow=new ti,this.shape=new ei,this.size=new si,this.stroke=new oi,this.zIndex=new ni}load(t){if(!t)return;if(void 0!==t.groups)for(const e of Object.keys(t.groups)){if(!Object.hasOwn(t.groups,e))continue;const i=t.groups[e];void 0!==i&&(this.groups[e]=st(this.groups[e]??{},i))}void 0!==t.reduceDuplicates&&(this.reduceDuplicates=t.reduceDuplicates),this.bounce.load(t.bounce),this.color.load(De.create(this.color,t.color)),this.effect.load(t.effect),this.move.load(t.move),this.number.load(t.number),this.opacity.load(t.opacity),this.shape.load(t.shape),this.size.load(t.size),this.shadow.load(t.shadow),this.zIndex.load(t.zIndex),this.collisions.load(t.collisions),void 0!==t.interactivity&&(this.interactivity=st({},t.interactivity));const e=t.stroke;if(e&&(this.stroke=dt(e,(t=>{const e=new oi;return e.load(t),e}))),this._container){const e=this._engine.updaters.get(this._container);if(e)for(const i of e)i.loadOptions&&i.loadOptions(this,t);const i=this._engine.interactors.get(this._container);if(i)for(const e of i)e.loadParticlesOptions&&e.loadParticlesOptions(this,t)}}}function ri(t,...e){for(const i of e)t.load(i)}function ci(t,e,...i){const s=new ai(t,e);return ri(s,...i),s}class li{constructor(t,e){this._findDefaultTheme=t=>this.themes.find((e=>e.default.value&&e.default.mode===t))??this.themes.find((t=>t.default.value&&"any"===t.default.mode)),this._importPreset=t=>{this.load(this._engine.getPreset(t))},this._engine=t,this._container=e,this.autoPlay=!0,this.background=new le,this.backgroundMask=new de,this.clear=!0,this.defaultThemes={},this.delay=0,this.fullScreen=new ue,this.detectRetina=!0,this.duration=0,this.fpsLimit=120,this.interactivity=new be(t,e),this.manualParticles=[],this.particles=ci(this._engine,this._container),this.pauseOnBlur=!0,this.pauseOnOutsideViewport=!0,this.responsive=[],this.smooth=!1,this.style={},this.themes=[],this.zLayers=100}load(t){if(!t)return;void 0!==t.preset&&dt(t.preset,(t=>this._importPreset(t))),void 0!==t.autoPlay&&(this.autoPlay=t.autoPlay),void 0!==t.clear&&(this.clear=t.clear),void 0!==t.name&&(this.name=t.name),void 0!==t.delay&&(this.delay=D(t.delay));const e=t.detectRetina;void 0!==e&&(this.detectRetina=e),void 0!==t.duration&&(this.duration=D(t.duration));const i=t.fpsLimit;void 0!==i&&(this.fpsLimit=i),void 0!==t.pauseOnBlur&&(this.pauseOnBlur=t.pauseOnBlur),void 0!==t.pauseOnOutsideViewport&&(this.pauseOnOutsideViewport=t.pauseOnOutsideViewport),void 0!==t.zLayers&&(this.zLayers=t.zLayers),this.background.load(t.background);const s=t.fullScreen;gt(s)?this.fullScreen.enable=s:this.fullScreen.load(s),this.backgroundMask.load(t.backgroundMask),this.interactivity.load(t.interactivity),t.manualParticles&&(this.manualParticles=t.manualParticles.map((t=>{const e=new xe;return e.load(t),e}))),this.particles.load(t.particles),this.style=st(this.style,t.style),this._engine.loadOptions(this,t),void 0!==t.smooth&&(this.smooth=t.smooth);const o=this._engine.interactors.get(this._container);if(o)for(const e of o)e.loadOptions&&e.loadOptions(this,t);if(void 0!==t.responsive)for(const e of t.responsive){const t=new _e;t.load(e),this.responsive.push(t)}if(this.responsive.sort(((t,e)=>t.maxWidth-e.maxWidth)),void 0!==t.themes)for(const e of t.themes){const t=this.themes.find((t=>t.name===e.name));if(t)t.load(e);else{const t=new Me;t.load(e),this.themes.push(t)}}this.defaultThemes.dark=this._findDefaultTheme("dark")?.name,this.defaultThemes.light=this._findDefaultTheme("light")?.name}setResponsive(t,e,i){this.load(i);const s=this.responsive.find((i=>"screen"===i.mode&&screen?i.maxWidth>screen.availWidth:i.maxWidth*e>t));return this.load(s?.options),s?.maxWidth}setTheme(t){if(t){const e=this.themes.find((e=>e.name===t));e&&this.load(e.options)}else{const t=N("(prefers-color-scheme: dark)"),e=t&&t.matches,i=this._findDefaultTheme(e?"dark":"light");i&&this.load(i.options)}}}class hi{constructor(t,e){this.container=e,this._engine=t,this._interactors=t.getInteractors(this.container,!0),this._externalInteractors=[],this._particleInteractors=[]}async externalInteract(t){for(const e of this._externalInteractors)e.isEnabled()&&await e.interact(t)}handleClickMode(t){for(const e of this._externalInteractors)e.handleClickMode&&e.handleClickMode(t)}init(){this._externalInteractors=[],this._particleInteractors=[];for(const t of this._interactors){switch(t.type){case"external":this._externalInteractors.push(t);break;case"particles":this._particleInteractors.push(t)}t.init()}}async particlesInteract(t,e){for(const i of this._externalInteractors)i.clear(t,e);for(const i of this._particleInteractors)i.isEnabled(t)&&await i.interact(t,e)}async reset(t){for(const e of this._externalInteractors)e.isEnabled()&&e.reset(t);for(const e of this._particleInteractors)e.isEnabled(t)&&e.reset(t)}}function di(t){if(!Z(t.outMode,t.checkModes))return;const e=2*t.radius;t.coord>t.maxCoord-e?t.setCb(-t.radius):t.coord{for(const[,s]of t.plugins){const t=void 0!==s.particlePosition?s.particlePosition(e,this):void 0;if(t)return m.create(t.x,t.y,i)}const o=B({size:t.canvas.size,position:e}),n=m.create(o.x,o.y,i),a=this.getRadius(),r=this.options.move.outModes,c=e=>{di({outMode:e,checkModes:["bounce","bounce-horizontal"],coord:n.x,maxCoord:t.canvas.size.width,setCb:t=>n.x+=t,radius:a})},l=e=>{di({outMode:e,checkModes:["bounce","bounce-vertical"],coord:n.y,maxCoord:t.canvas.size.height,setCb:t=>n.y+=t,radius:a})};return c(r.left??r.default),c(r.right??r.default),l(r.top??r.default),l(r.bottom??r.default),this._checkOverlap(n,s)?this._calcPosition(t,void 0,i,s+1):n},this._calculateVelocity=()=>{const t=E(this.direction).copy(),e=this.options.move;if("inside"===e.direction||"outside"===e.direction)return t;const i=Math.PI/180*P(e.angle.value),s=Math.PI/180*P(e.angle.offset),o={left:s-.5*i,right:s+.5*i};return e.straight||(t.angle+=C(D(o.left,o.right))),e.random&&"number"==typeof e.speed&&(t.length*=_()),t},this._checkOverlap=(t,e=0)=>{const i=this.options.collisions,s=this.getRadius();if(!i.enable)return!1;const o=i.overlap;if(o.enable)return!1;const n=o.retries;if(n>=0&&e>n)throw new Error(`${f} particle is overlapping and can't be placed`);return!!this.container.particles.find((e=>T(t,e.position){if(!t||!this.roll||!this.backColor&&!this.roll.alter)return t;const e=this.roll.horizontal&&this.roll.vertical?2:1,i=this.roll.horizontal?.5*Math.PI:0;return Math.floor(((this.roll.angle??0)+i)/(Math.PI/e))%2?this.backColor?this.backColor:this.roll.alter?se(t,this.roll.alter.type,this.roll.alter.value):t:t},this._initPosition=t=>{const e=this.container,i=P(this.options.zIndex.value);this.position=this._calcPosition(e,t,z(i,0,e.zLayers)),this.initialPosition=this.position.copy();const s=e.canvas.size;switch(this.moveCenter={...vt(this.options.move.center,s),radius:this.options.move.center.radius??0,mode:this.options.move.center.mode??"percent"},this.direction=I(this.options.move.direction,this.position,this.moveCenter),this.options.move.direction){case"inside":this.outType="inside";break;case"outside":this.outType="outside"}this.offset=v.origin},this._engine=t,this.init(e,s,o,n)}destroy(t){if(this.unbreakable||this.destroyed)return;this.destroyed=!0,this.bubble.inRange=!1,this.slow.inRange=!1;const e=this.container,i=this.pathGenerator,s=e.shapeDrawers.get(this.shape);s&&s.particleDestroy&&s.particleDestroy(this);for(const[,i]of e.plugins)i.particleDestroyed&&i.particleDestroyed(this,t);for(const i of e.particles.updaters)i.particleDestroyed&&i.particleDestroyed(this,t);i&&i.reset(this),this._engine.dispatchEvent("particleDestroyed",{container:this.container,data:{particle:this}})}draw(t){const e=this.container,i=e.canvas;for(const[,s]of e.plugins)i.drawParticlePlugin(s,this,t);i.drawParticle(this,t)}getFillColor(){return this._getRollColor(this.bubble.color??Ht(this.color))}getMass(){return this.getRadius()**2*Math.PI*.5}getPosition(){return{x:this.position.x+this.offset.x,y:this.position.y+this.offset.y,z:this.position.z}}getRadius(){return this.bubble.radius??this.size.value}getStrokeColor(){return this._getRollColor(this.bubble.color??Ht(this.strokeColor))}init(t,e,i,s){const o=this.container,n=this._engine;this.id=t,this.group=s,this.effectClose=!0,this.effectFill=!0,this.shapeClose=!0,this.shapeFill=!0,this.pathRotation=!1,this.lastPathTime=0,this.destroyed=!1,this.unbreakable=!1,this.rotation=0,this.misplaced=!1,this.retina={maxDistance:{}},this.outType="normal",this.ignoresResizeRatio=!0;const a=o.retina.pixelRatio,r=o.actualOptions,c=ci(this._engine,o,r.particles),l=c.effect.type,h=c.shape.type,{reduceDuplicates:d}=c;this.effect=ut(l,this.id,d),this.shape=ut(h,this.id,d);const u=c.effect,p=c.shape;if(i){if(i.effect&&i.effect.type){const t=ut(i.effect.type,this.id,d);t&&(this.effect=t,u.load(i.effect))}if(i.shape&&i.shape.type){const t=ut(i.shape.type,this.id,d);t&&(this.shape=t,p.load(i.shape))}}this.effectData=function(t,e,i,s){const o=e.options[t];if(o)return st({close:e.close,fill:e.fill},ut(o,i,s))}(this.effect,u,this.id,d),this.shapeData=function(t,e,i,s){const o=e.options[t];if(o)return st({close:e.close,fill:e.fill},ut(o,i,s))}(this.shape,p,this.id,d),c.load(i);const f=this.effectData;f&&c.load(f.particles);const m=this.shapeData;m&&c.load(m.particles);const v=new be(n,o);v.load(o.actualOptions.interactivity),v.load(c.interactivity),this.interactivity=v,this.effectFill=f?.fill??c.effect.fill,this.effectClose=f?.close??c.effect.close,this.shapeFill=m?.fill??c.shape.fill,this.shapeClose=m?.close??c.shape.close,this.options=c;const y=this.options.move.path;this.pathDelay=1e3*P(y.delay.value),y.generator&&(this.pathGenerator=this._engine.getPathGenerator(y.generator),this.pathGenerator&&o.addPath(y.generator,this.pathGenerator)&&this.pathGenerator.init(o)),o.retina.initParticle(this),this.size=ft(this.options.size,a),this.bubble={inRange:!1},this.slow={inRange:!1,factor:1},this._initPosition(e),this.initialVelocity=this._calculateVelocity(),this.velocity=this.initialVelocity.copy(),this.moveDecay=1-P(this.options.move.decay);const g=o.particles;g.setLastZIndex(this.position.z),this.zIndexFactor=this.position.z/o.zLayers,this.sides=24;let w=o.effectDrawers.get(this.effect);w||(w=this._engine.getEffectDrawer(this.effect),w&&o.effectDrawers.set(this.effect,w)),w&&w.loadEffect&&w.loadEffect(this);let b=o.shapeDrawers.get(this.shape);b||(b=this._engine.getShapeDrawer(this.shape),b&&o.shapeDrawers.set(this.shape,b)),b&&b.loadShape&&b.loadShape(this);const x=b?.getSidesCount;x&&(this.sides=x(this)),this.spawning=!1,this.shadowColor=Dt(this.options.shadow.color);for(const t of g.updaters)t.init(this);for(const t of g.movers)t.init&&t.init(this);w&&w.particleInit&&w.particleInit(o,this),b&&b.particleInit&&b.particleInit(o,this);for(const[,t]of o.plugins)t.particleCreated&&t.particleCreated(this)}isInsideCanvas(){const t=this.getRadius(),e=this.container.canvas.size,i=this.position;return i.x>=-t&&i.y>=-t&&i.y<=e.height+t&&i.x<=e.width+t}isVisible(){return!this.destroyed&&!this.spawning&&this.isInsideCanvas()}reset(){for(const t of this.container.particles.updaters)t.reset&&t.reset(this)}}class pi{constructor(t,e){this.position=t,this.particle=e}}class fi{constructor(t,e){this.position={x:t,y:e}}}class mi extends fi{constructor(t,e,i,s){super(t,e),this.size={height:s,width:i}}contains(t){const e=this.size.width,i=this.size.height,s=this.position;return t.x>=s.x&&t.x<=s.x+e&&t.y>=s.y&&t.y<=s.y+i}intersects(t){t instanceof vi&&t.intersects(this);const e=this.size.width,i=this.size.height,s=this.position,o=t.position,n=t instanceof mi?t.size:{width:0,height:0},a=n.width,r=n.height;return o.xs.x&&o.ys.y}}class vi extends fi{constructor(t,e,i){super(t,e),this.radius=i}contains(t){return T(t,this.position)<=this.radius}intersects(t){const e=this.position,i=t.position,s=Math.abs(i.x-e.x),o=Math.abs(i.y-e.y),n=this.radius;if(t instanceof vi){return n+t.radius>Math.sqrt(s**2+o**2)}if(t instanceof mi){const{width:e,height:i}=t.size;return Math.pow(s-e,2)+Math.pow(o-i,2)<=n**2||s<=n+e&&o<=n+i||s<=e||o<=i}return!1}}class yi{constructor(t,e){this.rectangle=t,this.capacity=e,this._subdivide=()=>{const{x:t,y:e}=this.rectangle.position,{width:i,height:s}=this.rectangle.size,{capacity:o}=this;for(let n=0;n<4;n++)this._subs.push(new yi(new mi(t+.5*i*(n%2),e+.5*s*(Math.round(.5*n)-n%2),.5*i,.5*s),o));this._divided=!0},this._points=[],this._divided=!1,this._subs=[]}insert(t){return!!this.rectangle.contains(t.position)&&(this._points.lengthe.insert(t)))))}query(t,e,i){const s=i||[];if(!t.intersects(this.rectangle))return[];for(const i of this._points)!t.contains(i.position)&&T(t.position,i.position)>i.particle.getRadius()&&(!e||e(i.particle))||s.push(i.particle);if(this._divided)for(const i of this._subs)i.query(t,e,s);return s}queryCircle(t,e,i){return this.query(new vi(t.x,t.y,e),i)}queryRectangle(t,e,i){return this.query(new mi(t.x,t.y,e.width,e.height),i)}}const gi=t=>{const{height:e,width:i}=t;return new mi(-.25*i,-.25*e,1.5*i,1.5*e)};class wi{constructor(t,e){this._addToPool=(...t)=>{for(const e of t)this._pool.push(e)},this._applyDensity=(t,e,i)=>{const s=t.number;if(!t.number.density?.enable)return void(void 0===i?this._limit=s.limit.value:s.limit&&this._groupLimits.set(i,s.limit.value));const o=this._initDensityFactor(s.density),n=s.value,a=s.limit.value>0?s.limit.value:n,r=Math.min(n,a)*o+e,c=Math.min(this.count,this.filter((t=>t.group===i)).length);void 0===i?this._limit=s.limit.value*o:this._groupLimits.set(i,s.limit.value*o),cr&&this.removeQuantity(c-r,i)},this._initDensityFactor=t=>{const e=this._container;if(!e.canvas.element||!t.enable)return 1;const i=e.canvas.element,s=e.retina.pixelRatio;return i.width*i.height/(t.height*t.width*s**2)},this._pushParticle=(t,e,i,s)=>{try{let o=this._pool.pop();o?o.init(this._nextId,t,e,i):o=new ui(this._engine,this._nextId,this._container,t,e,i);let n=!0;if(s&&(n=s(o)),!n)return;return this._array.push(o),this._zArray.push(o),this._nextId++,this._engine.dispatchEvent("particleAdded",{container:this._container,data:{particle:o}}),o}catch(t){return void G().warning(`${f} adding particle: ${t}`)}},this._removeParticle=(t,e,i)=>{const s=this._array[t];if(!s||s.group!==e)return!1;const o=this._zArray.indexOf(s);return this._array.splice(t,1),this._zArray.splice(o,1),s.destroy(i),this._engine.dispatchEvent("particleRemoved",{container:this._container,data:{particle:s}}),this._addToPool(s),!0},this._engine=t,this._container=e,this._nextId=0,this._array=[],this._zArray=[],this._pool=[],this._limit=0,this._groupLimits=new Map,this._needsSort=!1,this._lastZIndex=0,this._interactionManager=new hi(t,e);const i=e.canvas.size;this.quadTree=new yi(gi(i),4),this.movers=this._engine.getMovers(e,!0),this.updaters=this._engine.getUpdaters(e,!0)}get count(){return this._array.length}addManualParticles(){const t=this._container,e=t.actualOptions;for(const i of e.manualParticles)this.addParticle(i.position?vt(i.position,t.canvas.size):void 0,i.options)}addParticle(t,e,i,s){const o=this._container.actualOptions.particles.number.limit,n=void 0===i?this._limit:this._groupLimits.get(i)??this._limit,a=this.count;if(n>0)if("delete"===o.mode){const t=a+1-n;t>0&&this.removeQuantity(t)}else if("wait"===o.mode&&a>=n)return;return this._pushParticle(t,e,i,s)}clear(){this._array=[],this._zArray=[]}destroy(){this._array=[],this._zArray=[],this.movers=[],this.updaters=[]}async draw(t){const e=this._container,i=e.canvas;i.clear(),await this.update(t);for(const[,s]of e.plugins)i.drawPlugin(s,t);for(const e of this._zArray)e.draw(t)}filter(t){return this._array.filter(t)}find(t){return this._array.find(t)}get(t){return this._array[t]}handleClickMode(t){this._interactionManager.handleClickMode(t)}init(){const t=this._container,e=t.actualOptions;this._lastZIndex=0,this._needsSort=!1;let i=!1;this.updaters=this._engine.getUpdaters(t,!0),this._interactionManager.init();for(const[,e]of t.plugins)if(void 0!==e.particlesInitialization&&(i=e.particlesInitialization()),i)break;this._interactionManager.init();for(const[,e]of t.pathGenerators)e.init(t);if(this.addManualParticles(),!i){const t=e.particles,i=t.groups;for(const e in i){const s=i[e];for(let i=this.count,o=0;othis.count)return;let o=0;for(let n=t;o!i.has(t);this._array=this.filter(t),this._zArray=this._zArray.filter(t);for(const t of i)this._engine.dispatchEvent("particleRemoved",{container:this._container,data:{particle:t}});this._addToPool(...i)}await this._interactionManager.externalInteract(t);for(const e of this._array){for(const i of this.updaters)i.update(e,t);e.destroyed||e.spawning||await this._interactionManager.particlesInteract(e,t)}if(delete this._resizeFactor,this._needsSort){const t=this._zArray;t.sort(((t,e)=>e.position.z-t.position.z||t.id-e.id)),this._lastZIndex=t[t.length-1].position.z,this._needsSort=!1}}}class bi{constructor(t){this.container=t,this.pixelRatio=1,this.reduceFactor=1}init(){const t=this.container,e=t.actualOptions;this.pixelRatio=!e.detectRetina||j()?1:window.devicePixelRatio,this.reduceFactor=1;const i=this.pixelRatio,s=t.canvas;if(s.element){const t=s.element;s.size.width=t.offsetWidth*i,s.size.height=t.offsetHeight*i}const o=e.particles,n=o.move;this.maxSpeed=P(n.gravity.maxSpeed)*i,this.sizeAnimationSpeed=P(o.size.animation.speed)*i}initParticle(t){const e=t.options,i=this.pixelRatio,s=e.move,o=s.distance,n=t.retina;n.moveDrift=P(s.drift)*i,n.moveSpeed=P(s.speed)*i,n.sizeAnimationSpeed=P(e.size.animation.speed)*i;const a=n.maxDistance;a.horizontal=void 0!==o.horizontal?o.horizontal*i:void 0,a.vertical=void 0!==o.vertical?o.vertical*i:void 0,n.maxSpeed=P(s.gravity.maxSpeed)*i}}function xi(t){return t&&!t.destroyed}function _i(t,e,...i){const s=new li(t,e);return ri(s,...i),s}class zi{constructor(t,e,i){this._intersectionManager=t=>{if(xi(this)&&this.actualOptions.pauseOnOutsideViewport)for(const e of t)e.target===this.interactivity.element&&(e.isIntersecting?this.play:this.pause)()},this._nextFrame=async t=>{try{if(!this._smooth&&void 0!==this._lastFrameTime&&t1e3)return void this.draw(!1);if(await this.particles.draw(e),!this.alive())return void this.destroy();this.getAnimationStatus()&&this.draw(!1)}catch(t){G().error(`${f} in animation loop`,t)}},this._engine=t,this.id=Symbol(e),this.fpsLimit=120,this._smooth=!1,this._delay=0,this._duration=0,this._lifeTime=0,this._firstStart=!0,this.started=!1,this.destroyed=!1,this._paused=!0,this._lastFrameTime=0,this.zLayers=100,this.pageHidden=!1,this._sourceOptions=i,this._initialSourceOptions=i,this.retina=new bi(this),this.canvas=new ne(this),this.particles=new wi(this._engine,this),this.pathGenerators=new Map,this.interactivity={mouse:{clicking:!1,inside:!1}},this.plugins=new Map,this.effectDrawers=new Map,this.shapeDrawers=new Map,this._options=_i(this._engine,this),this.actualOptions=_i(this._engine,this),this._eventListeners=new re(this),this._intersectionObserver=X((t=>this._intersectionManager(t))),this._engine.dispatchEvent("containerBuilt",{container:this})}get options(){return this._options}get sourceOptions(){return this._sourceOptions}addClickHandler(t){if(!xi(this))return;const e=this.interactivity.element;if(!e)return;const i=(e,i,s)=>{if(!xi(this))return;const o=this.retina.pixelRatio,n={x:i.x*o,y:i.y*o},a=this.particles.quadTree.queryCircle(n,s*o);t(e,a)};let s=!1,o=!1;e.addEventListener("click",(t=>{if(!xi(this))return;const e=t,s={x:e.offsetX||e.clientX,y:e.offsetY||e.clientY};i(t,s,1)})),e.addEventListener("touchstart",(()=>{xi(this)&&(s=!0,o=!1)})),e.addEventListener("touchmove",(()=>{xi(this)&&(o=!0)})),e.addEventListener("touchend",(t=>{if(xi(this)){if(s&&!o){const e=t;let s=e.touches[e.touches.length-1];if(!s&&(s=e.changedTouches[e.changedTouches.length-1],!s))return;const o=this.canvas.element,n=o?o.getBoundingClientRect():void 0,a={x:s.clientX-(n?n.left:0),y:s.clientY-(n?n.top:0)};i(t,a,Math.max(s.radiusX,s.radiusY))}s=!1,o=!1}})),e.addEventListener("touchcancel",(()=>{xi(this)&&(s=!1,o=!1)}))}addLifeTime(t){this._lifeTime+=t}addPath(t,e,i=!1){return!(!xi(this)||!i&&this.pathGenerators.has(t))&&(this.pathGenerators.set(t,e),!0)}alive(){return!this._duration||this._lifeTime<=this._duration}destroy(){if(!xi(this))return;this.stop(),this.particles.destroy(),this.canvas.destroy();for(const[,t]of this.effectDrawers)t.destroy&&t.destroy(this);for(const[,t]of this.shapeDrawers)t.destroy&&t.destroy(this);for(const t of this.effectDrawers.keys())this.effectDrawers.delete(t);for(const t of this.shapeDrawers.keys())this.shapeDrawers.delete(t);this._engine.clearPlugins(this),this.destroyed=!0;const t=this._engine.dom(),e=t.findIndex((t=>t===this));e>=0&&t.splice(e,1),this._engine.dispatchEvent("containerDestroyed",{container:this})}draw(t){if(!xi(this))return;let e=t;this._drawAnimationFrame=requestAnimationFrame((async t=>{e&&(this._lastFrameTime=void 0,e=!1),await this._nextFrame(t)}))}async export(t,e={}){for(const[,i]of this.plugins){if(!i.export)continue;const s=await i.export(t,e);if(s.supported)return s.blob}G().error(`${f} - Export plugin with type ${t} not found`)}getAnimationStatus(){return!this._paused&&!this.pageHidden&&xi(this)}handleClickMode(t){if(xi(this)){this.particles.handleClickMode(t);for(const[,e]of this.plugins)e.handleClickMode&&e.handleClickMode(t)}}async init(){if(!xi(this))return;const t=this._engine.getSupportedEffects();for(const e of t){const t=this._engine.getEffectDrawer(e);t&&this.effectDrawers.set(e,t)}const e=this._engine.getSupportedShapes();for(const t of e){const e=this._engine.getShapeDrawer(t);e&&this.shapeDrawers.set(t,e)}this._options=_i(this._engine,this,this._initialSourceOptions,this.sourceOptions),this.actualOptions=_i(this._engine,this,this._options);const i=this._engine.getAvailablePlugins(this);for(const[t,e]of i)this.plugins.set(t,e);this.retina.init(),await this.canvas.init(),this.updateActualOptions(),this.canvas.initBackground(),this.canvas.resize(),this.zLayers=this.actualOptions.zLayers,this._duration=1e3*P(this.actualOptions.duration),this._delay=1e3*P(this.actualOptions.delay),this._lifeTime=0,this.fpsLimit=this.actualOptions.fpsLimit>0?this.actualOptions.fpsLimit:120,this._smooth=this.actualOptions.smooth;for(const[,t]of this.effectDrawers)t.init&&await t.init(this);for(const[,t]of this.shapeDrawers)t.init&&await t.init(this);for(const[,t]of this.plugins)t.init&&await t.init();this._engine.dispatchEvent("containerInit",{container:this}),this.particles.init(),this.particles.setDensity();for(const[,t]of this.plugins)t.particlesSetup&&t.particlesSetup();this._engine.dispatchEvent("particlesSetup",{container:this})}async loadTheme(t){xi(this)&&(this._currentTheme=t,await this.refresh())}pause(){if(xi(this)&&(void 0!==this._drawAnimationFrame&&(cancelAnimationFrame(this._drawAnimationFrame),delete this._drawAnimationFrame),!this._paused)){for(const[,t]of this.plugins)t.pause&&t.pause();this.pageHidden||(this._paused=!0),this._engine.dispatchEvent("containerPaused",{container:this})}}play(t){if(!xi(this))return;const e=this._paused||t;if(!this._firstStart||this.actualOptions.autoPlay){if(this._paused&&(this._paused=!1),e)for(const[,t]of this.plugins)t.play&&t.play();this._engine.dispatchEvent("containerPlay",{container:this}),this.draw(e||!1)}else this._firstStart=!1}async refresh(){if(xi(this))return this.stop(),this.start()}async reset(){if(xi(this))return this._initialSourceOptions=void 0,this._options=_i(this._engine,this),this.actualOptions=_i(this._engine,this,this._options),this.refresh()}async start(){xi(this)&&!this.started&&(await this.init(),this.started=!0,await new Promise((t=>{this._delayTimeout=setTimeout((async()=>{this._eventListeners.addListeners(),this.interactivity.element instanceof HTMLElement&&this._intersectionObserver&&this._intersectionObserver.observe(this.interactivity.element);for(const[,t]of this.plugins)t.start&&await t.start();this._engine.dispatchEvent("containerStarted",{container:this}),this.play(),t()}),this._delay)})))}stop(){if(xi(this)&&this.started){this._delayTimeout&&(clearTimeout(this._delayTimeout),delete this._delayTimeout),this._firstStart=!0,this.started=!1,this._eventListeners.removeListeners(),this.pause(),this.particles.clear(),this.canvas.stop(),this.interactivity.element instanceof HTMLElement&&this._intersectionObserver&&this._intersectionObserver.unobserve(this.interactivity.element);for(const[,t]of this.plugins)t.stop&&t.stop();for(const t of this.plugins.keys())this.plugins.delete(t);this._sourceOptions=this._options,this._engine.dispatchEvent("containerStopped",{container:this})}}updateActualOptions(){this.actualOptions.responsive=[];const t=this.actualOptions.setResponsive(this.canvas.size.width,this.retina.pixelRatio,this._options);return this.actualOptions.setTheme(this._currentTheme),this._responsiveMaxWidth!==t&&(this._responsiveMaxWidth=t,!0)}}class Mi{constructor(){this._listeners=new Map}addEventListener(t,e){this.removeEventListener(t,e);let i=this._listeners.get(t);i||(i=[],this._listeners.set(t,i)),i.push(e)}dispatchEvent(t,e){const i=this._listeners.get(t);i&&i.forEach((t=>t(e)))}hasEventListener(t){return!!this._listeners.get(t)}removeAllEventListeners(t){t?this._listeners.delete(t):this._listeners=new Map}removeEventListener(t,e){const i=this._listeners.get(t);if(!i)return;const s=i.length,o=i.indexOf(e);o<0||(1===s?this._listeners.delete(t):i.splice(o,1))}}function Ci(t,e,i,s=!1){let o=e.get(t);return o&&!s||(o=[...i.values()].map((e=>e(t))),e.set(t,o)),o}class Pi{constructor(){this._configs=new Map,this._domArray=[],this._eventDispatcher=new Mi,this._initialized=!1,this.plugins=[],this._initializers={interactors:new Map,movers:new Map,updaters:new Map},this.interactors=new Map,this.movers=new Map,this.updaters=new Map,this.presets=new Map,this.effectDrawers=new Map,this.shapeDrawers=new Map,this.pathGenerators=new Map}get configs(){const t={};for(const[e,i]of this._configs)t[e]=i;return t}get version(){return"3.0.3"}addConfig(t){const e=t.name??"default";this._configs.set(e,t),this._eventDispatcher.dispatchEvent("configAdded",{data:{name:e,config:t}})}async addEffect(t,e,i=!0){dt(t,(t=>{!this.getEffectDrawer(t)&&this.effectDrawers.set(t,e)})),await this.refresh(i)}addEventListener(t,e){this._eventDispatcher.addEventListener(t,e)}async addInteractor(t,e,i=!0){this._initializers.interactors.set(t,e),await this.refresh(i)}async addMover(t,e,i=!0){this._initializers.movers.set(t,e),await this.refresh(i)}async addParticleUpdater(t,e,i=!0){this._initializers.updaters.set(t,e),await this.refresh(i)}async addPathGenerator(t,e,i=!0){!this.getPathGenerator(t)&&this.pathGenerators.set(t,e),await this.refresh(i)}async addPlugin(t,e=!0){!this.getPlugin(t.id)&&this.plugins.push(t),await this.refresh(e)}async addPreset(t,e,i=!1,s=!0){(i||!this.getPreset(t))&&this.presets.set(t,e),await this.refresh(s)}async addShape(t,e,i=!0){dt(t,(t=>{!this.getShapeDrawer(t)&&this.shapeDrawers.set(t,e)})),await this.refresh(i)}clearPlugins(t){this.updaters.delete(t),this.movers.delete(t),this.interactors.delete(t)}dispatchEvent(t,e){this._eventDispatcher.dispatchEvent(t,e)}dom(){return this._domArray}domItem(t){const e=this.dom(),i=e[t];if(i&&!i.destroyed)return i;e.splice(t,1)}getAvailablePlugins(t){const e=new Map;for(const i of this.plugins)i.needsPlugin(t.actualOptions)&&e.set(i.id,i.getPlugin(t));return e}getEffectDrawer(t){return this.effectDrawers.get(t)}getInteractors(t,e=!1){return Ci(t,this.interactors,this._initializers.interactors,e)}getMovers(t,e=!1){return Ci(t,this.movers,this._initializers.movers,e)}getPathGenerator(t){return this.pathGenerators.get(t)}getPlugin(t){return this.plugins.find((e=>e.id===t))}getPreset(t){return this.presets.get(t)}getShapeDrawer(t){return this.shapeDrawers.get(t)}getSupportedEffects(){return this.effectDrawers.keys()}getSupportedShapes(){return this.shapeDrawers.keys()}getUpdaters(t,e=!1){return Ci(t,this.updaters,this._initializers.updaters,e)}init(){this._initialized||(this._initialized=!0)}async load(t){const e=t.id??t.element?.id??`tsparticles${Math.floor(1e4*_())}`,{index:s,url:o}=t,n=o?await async function(t){const e=ut(t.url,t.index);if(!e)return t.fallback;const i=await fetch(e);return i.ok?i.json():(G().error(`${f} ${i.status} while retrieving config file`),t.fallback)}({fallback:t.options,url:o,index:s}):t.options;let a=t.element??document.getElementById(e);a||(a=document.createElement("div"),a.id=e,document.body.append(a));const r=ut(n,s),c=this.dom(),l=c.findIndex((t=>t.id.description===e));if(l>=0){const t=this.domItem(l);t&&!t.destroyed&&(t.destroy(),c.splice(l,1))}let h;if("canvas"===a.tagName.toLowerCase())h=a,h.dataset[i]="false";else{const t=a.getElementsByTagName("canvas");t.length?(h=t[0],h.dataset[i]="false"):(h=document.createElement("canvas"),h.dataset[i]="true",a.appendChild(h))}h.style.width||(h.style.width="100%"),h.style.height||(h.style.height="100%");const d=new zi(this,e,r);return l>=0?c.splice(l,0,d):c.push(d),d.canvas.loadCanvas(h),await d.start(),d}loadOptions(t,e){for(const i of this.plugins)i.loadOptions(t,e)}loadParticlesOptions(t,e,...i){const s=this.updaters.get(t);if(s)for(const t of s)t.loadOptions&&t.loadOptions(e,...i)}async refresh(t=!0){t&&this.dom().forEach((t=>t.refresh()))}removeEventListener(t,e){this._eventDispatcher.removeEventListener(t,e)}setOnClickHandler(t){const e=this.dom();if(!e.length)throw new Error(`${f} can only set click handlers after calling tsParticles.load()`);for(const i of e)i.addClickHandler(t)}}class Si{constructor(){this.key="hsl",this.stringPrefix="hsl"}handleColor(t){const e=t.value.hsl??t.value;if(void 0!==e.h&&void 0!==e.s&&void 0!==e.l)return Lt(e)}handleRangeColor(t){const e=t.value.hsl??t.value;if(void 0!==e.h&&void 0!==e.l)return Lt({h:P(e.h),l:P(e.l),s:P(e.s)})}parseString(t){if(!t.startsWith("hsl"))return;const e=/hsla?\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(,\s*([\d.%]+)\s*)?\)/i.exec(t);return e?At({a:e.length>4?U(e[5]):1,h:parseInt(e[1],10),l:parseInt(e[3],10),s:parseInt(e[2],10)}):void 0}}class ki{constructor(){this.key="rgb",this.stringPrefix="rgb"}handleColor(t){const e=t.value.rgb??t.value;if(void 0!==e.r)return e}handleRangeColor(t){const e=t.value.rgb??t.value;if(void 0!==e.r)return{r:P(e.r),g:P(e.g),b:P(e.b)}}parseString(t){if(!t.startsWith(this.stringPrefix))return;const e=/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(,\s*([\d.%]+)\s*)?\)/i.exec(t);return e?{a:e.length>4?U(e[5]):1,b:parseInt(e[3],10),g:parseInt(e[2],10),r:parseInt(e[1],10)}:void 0}}class Di{constructor(t){this.container=t,this.type="external"}}class Oi{constructor(t){this.container=t,this.type="particles"}}const Ti=function(){const t=new ki,e=new Si;St(t),St(e);const i=new Pi;return i.init(),i}();j()||(window.tsParticles=Ti);class Ii{constructor(){this.angle=90,this.count=50,this.spread=45,this.startVelocity=45,this.decay=.9,this.gravity=1,this.drift=0,this.ticks=200,this.position={x:50,y:50},this.colors=["#26ccff","#a25afd","#ff5e7e","#88ff5a","#fcff42","#ffa62d","#ff36ff"],this.shapes=["square","circle"],this.scalar=1,this.zIndex=100,this.disableForReducedMotion=!0,this.flat=!1,this.shapeOptions={}}get origin(){return{x:this.position.x/100,y:this.position.y/100}}set origin(t){this.position.x=100*t.x,this.position.y=100*t.y}get particleCount(){return this.count}set particleCount(t){this.count=t}load(t){if(!t)return;void 0!==t.angle&&(this.angle=t.angle);const e=t.count??t.particleCount;void 0!==e&&(this.count=e),void 0!==t.spread&&(this.spread=t.spread),void 0!==t.startVelocity&&(this.startVelocity=t.startVelocity),void 0!==t.decay&&(this.decay=t.decay),void 0!==t.flat&&(this.flat=t.flat),void 0!==t.gravity&&(this.gravity=t.gravity),void 0!==t.drift&&(this.drift=t.drift),void 0!==t.ticks&&(this.ticks=t.ticks);const i=t.origin;i&&!t.position&&(t.position={x:void 0!==i.x?100*i.x:void 0,y:void 0!==i.y?100*i.y:void 0});const s=t.position;s&&(void 0!==s.x&&(this.position.x=s.x),void 0!==s.y&&(this.position.y=s.y)),void 0!==t.colors&&(zt(t.colors)?this.colors=[...t.colors]:this.colors=t.colors);const o=t.shapeOptions;if(void 0!==o)for(const t in o){const e=o[t];e&&(this.shapeOptions[t]=st(this.shapeOptions[t]??{},e))}void 0!==t.shapes&&(zt(t.shapes)?this.shapes=[...t.shapes]:this.shapes=t.shapes),void 0!==t.scalar&&(this.scalar=t.scalar),void 0!==t.zIndex&&(this.zIndex=t.zIndex),void 0!==t.disableForReducedMotion&&(this.disableForReducedMotion=t.disableForReducedMotion)}}function Ei(t,e,i,s,o,n){!function(t,e){const i=t.options,s=i.move.path;if(!s.enable)return;if(t.lastPathTime<=t.pathDelay)return void(t.lastPathTime+=e.value);const o=t.pathGenerator?.generate(t,e);o&&t.velocity.addTo(o);s.clamp&&(t.velocity.x=z(t.velocity.x,-1,1),t.velocity.y=z(t.velocity.y,-1,1));t.lastPathTime-=t.pathDelay}(t,n);const a=t.gravity,r=a?.enable&&a.inverse?-1:1;o&&i&&(t.velocity.x+=o*n.factor/(60*i)),a?.enable&&i&&(t.velocity.y+=r*(a.acceleration*n.factor)/(60*i));const c=t.moveDecay;t.velocity.multTo(c);const l=t.velocity.mult(i);a?.enable&&s>0&&(!a.inverse&&l.y>=0&&l.y>=s||a.inverse&&l.y<=0&&l.y<=-s)&&(l.y=r*s,i&&(t.velocity.y=l.y/i));const h=t.options.zIndex,d=(1-t.zIndexFactor)**h.velocityRate;l.multTo(d);const{position:u}=t;u.addTo(l),e.vibrate&&(u.x+=Math.sin(u.x*Math.cos(u.y)),u.y+=Math.cos(u.y*Math.sin(u.x)))}class Ri{constructor(){this._initSpin=t=>{const e=t.container,i=t.options.move.spin;if(!i.enable)return;const s=i.position??{x:50,y:50},o={x:.01*s.x*e.canvas.size.width,y:.01*s.y*e.canvas.size.height},n=T(t.getPosition(),o),a=P(i.acceleration);t.retina.spinAcceleration=a*e.retina.pixelRatio,t.spin={center:o,direction:t.velocity.x>=0?"clockwise":"counter-clockwise",angle:t.velocity.angle,radius:n,acceleration:t.retina.spinAcceleration}}}init(t){const e=t.options.move.gravity;t.gravity={enable:e.enable,acceleration:P(e.acceleration),inverse:e.inverse},this._initSpin(t)}isEnabled(t){return!t.destroyed&&t.options.move.enable}move(t,e){const i=t.options,s=i.move;if(!s.enable)return;const o=t.container,n=o.retina.pixelRatio,a=function(t){return t.slow.inRange?t.slow.factor:1}(t),r=(t.retina.moveSpeed??=P(s.speed)*n)*o.retina.reduceFactor,c=t.retina.moveDrift??=P(t.options.move.drift)*n,l=k(i.size.value)*n,h=r*(s.size?t.getRadius()/l:1)*a*(e.factor||1)/2,d=t.retina.maxSpeed??o.retina.maxSpeed;s.spin.enable?function(t,e){const i=t.container;if(!t.spin)return;const s={x:"clockwise"===t.spin.direction?Math.cos:Math.sin,y:"clockwise"===t.spin.direction?Math.sin:Math.cos};t.position.x=t.spin.center.x+t.spin.radius*s.x(t.spin.angle),t.position.y=t.spin.center.y+t.spin.radius*s.y(t.spin.angle),t.spin.radius+=t.spin.acceleration;const o=Math.max(i.canvas.size.width,i.canvas.size.height),n=.5*o;t.spin.radius>n?(t.spin.radius=n,t.spin.acceleration*=-1):t.spin.radius<0&&(t.spin.radius=0,t.spin.acceleration*=-1),t.spin.angle+=.01*e*(1-t.spin.radius/o)}(t,h):Ei(t,s,h,d,c,e),function(t){const e=t.initialPosition,{dx:i,dy:s}=O(e,t.position),o=Math.abs(i),n=Math.abs(s),{maxDistance:a}=t.retina,r=a.horizontal,c=a.vertical;if(r||c)if((r&&o>=r||c&&n>=c)&&!t.misplaced)t.misplaced=!!r&&o>r||!!c&&n>c,r&&(t.velocity.x=.5*t.velocity.y-t.velocity.x),c&&(t.velocity.y=.5*t.velocity.x-t.velocity.y);else if((!r||oe.x&&s.x>0)&&(s.x*=-_()),c&&(i.ye.y&&s.y>0)&&(s.y*=-_())}}(t)}}class Fi{draw(t){const{context:e,particle:i,radius:s}=t;i.circleRange||(i.circleRange={min:0,max:2*Math.PI});const o=i.circleRange;e.arc(0,0,s,o.min,o.max,!1)}getSidesCount(){return 12}particleInit(t,e){const i=e.shapeData,s=i?.angle??{max:360,min:0};e.circleRange=_t(s)?{min:s.min*Math.PI/180,max:s.max*Math.PI/180}:{min:0,max:s*Math.PI/180}}}function Li(t,e,i,s,o){if(!e||!i.enable||(e.maxLoops??0)>0&&(e.loops??0)>(e.maxLoops??0))return;if(e.time||(e.time=0),(e.delayTime??0)>0&&e.time<(e.delayTime??0)&&(e.time+=t.value),(e.delayTime??0)>0&&e.time<(e.delayTime??0))return;const n=C(i.offset),a=(e.velocity??0)*t.factor+3.6*n,r=e.decay??1;o&&"increasing"!==e.status?(e.value-=a,e.value<0&&(e.loops||(e.loops=0),e.loops++,e.status="increasing",e.value+=e.value)):(e.value+=a,e.value>s&&(e.loops||(e.loops=0),e.loops++,o&&(e.status="decreasing",e.value-=e.value%s))),e.velocity&&1!==r&&(e.velocity*=r),e.value>s&&(e.value%=s)}class Ai{constructor(t){this.container=t}init(t){const e=It(t.options.color,t.id,t.options.reduceDuplicates);e&&(t.color=jt(e,t.options.color.animation,this.container.retina.reduceFactor))}isEnabled(t){const{h:e,s:i,l:s}=t.options.color.animation,{color:o}=t;return!t.destroyed&&!t.spawning&&(void 0!==o?.h.value&&e.enable||void 0!==o?.s.value&&i.enable||void 0!==o?.l.value&&s.enable)}update(t,e){!function(t,e){const{h:i,s,l:o}=t.options.color.animation,{color:n}=t;if(!n)return;const{h:a,s:r,l:c}=n;a&&Li(e,a,i,360,!1),r&&Li(e,r,s,100,!0),c&&Li(e,c,o,100,!0)}(t,e)}}class Bi{constructor(t){this.container=t}init(t){const e=t.options.opacity;t.opacity=ft(e,1);const i=e.animation;i.enable&&(t.opacity.velocity=P(i.speed)/100*this.container.retina.reduceFactor,i.sync||(t.opacity.velocity*=_()))}isEnabled(t){return!t.destroyed&&!t.spawning&&!!t.opacity&&t.opacity.enable&&((t.opacity.maxLoops??0)<=0||(t.opacity.maxLoops??0)>0&&(t.opacity.loops??0)<(t.opacity.maxLoops??0))}reset(t){t.opacity&&(t.opacity.time=0,t.opacity.loops=0)}update(t,e){this.isEnabled(t)&&function(t,e){const i=t.opacity;if(t.destroyed||!i?.enable||(i.maxLoops??0)>0&&(i.loops??0)>(i.maxLoops??0))return;const s=i.min,o=i.max,n=i.decay??1;if(i.time||(i.time=0),(i.delayTime??0)>0&&i.time<(i.delayTime??0)&&(i.time+=e.value),!((i.delayTime??0)>0&&i.time<(i.delayTime??0))){switch(i.status){case"increasing":i.value>=o?(i.status="decreasing",i.loops||(i.loops=0),i.loops++):i.value+=(i.velocity??0)*e.factor;break;case"decreasing":i.value<=s?(i.status="increasing",i.loops||(i.loops=0),i.loops++):i.value-=(i.velocity??0)*e.factor}i.velocity&&1!==i.decay&&(i.velocity*=n),function(t,e,i,s){switch(t.options.opacity.animation.destroy){case"max":e>=s&&t.destroy();break;case"min":e<=i&&t.destroy()}}(t,i.value,s,o),t.destroyed||(i.value=z(i.value,s,o))}}(t,e)}}class Vi{constructor(t){this.container=t,this.modes=["bounce","bounce-vertical","bounce-horizontal","bounceVertical","bounceHorizontal","split"]}update(t,e,i,s){if(!this.modes.includes(s))return;const o=this.container;let n=!1;for(const[,s]of o.plugins)if(void 0!==s.particleBounce&&(n=s.particleBounce(t,i,e)),n)break;if(n)return;const a=t.getPosition(),r=t.offset,c=t.getRadius(),l=it(a,c),h=o.canvas.size;!function(t){if("bounce"!==t.outMode&&"bounce-horizontal"!==t.outMode&&"bounceHorizontal"!==t.outMode&&"split"!==t.outMode||"left"!==t.direction&&"right"!==t.direction)return;t.bounds.right<0&&"left"===t.direction?t.particle.position.x=t.size+t.offset.x:t.bounds.left>t.canvasSize.width&&"right"===t.direction&&(t.particle.position.x=t.canvasSize.width-t.size-t.offset.x);const e=t.particle.velocity.x;let i=!1;if("right"===t.direction&&t.bounds.right>=t.canvasSize.width&&e>0||"left"===t.direction&&t.bounds.left<=0&&e<0){const e=P(t.particle.options.bounce.horizontal.value);t.particle.velocity.x*=-e,i=!0}if(!i)return;const s=t.offset.x+t.size;t.bounds.right>=t.canvasSize.width&&"right"===t.direction?t.particle.position.x=t.canvasSize.width-s:t.bounds.left<=0&&"left"===t.direction&&(t.particle.position.x=s),"split"===t.outMode&&t.particle.destroy()}({particle:t,outMode:s,direction:e,bounds:l,canvasSize:h,offset:r,size:c}),function(t){if("bounce"!==t.outMode&&"bounce-vertical"!==t.outMode&&"bounceVertical"!==t.outMode&&"split"!==t.outMode||"bottom"!==t.direction&&"top"!==t.direction)return;t.bounds.bottom<0&&"top"===t.direction?t.particle.position.y=t.size+t.offset.y:t.bounds.top>t.canvasSize.height&&"bottom"===t.direction&&(t.particle.position.y=t.canvasSize.height-t.size-t.offset.y);const e=t.particle.velocity.y;let i=!1;if("bottom"===t.direction&&t.bounds.bottom>=t.canvasSize.height&&e>0||"top"===t.direction&&t.bounds.top<=0&&e<0){const e=P(t.particle.options.bounce.vertical.value);t.particle.velocity.y*=-e,i=!0}if(!i)return;const s=t.offset.y+t.size;t.bounds.bottom>=t.canvasSize.height&&"bottom"===t.direction?t.particle.position.y=t.canvasSize.height-s:t.bounds.top<=0&&"top"===t.direction&&(t.particle.position.y=s),"split"===t.outMode&&t.particle.destroy()}({particle:t,outMode:s,direction:e,bounds:l,canvasSize:h,offset:r,size:c})}}class Ui{constructor(t){this.container=t,this.modes=["destroy"]}update(t,e,i,s){if(!this.modes.includes(s))return;const o=this.container;switch(t.outType){case"normal":case"outside":if(tt(t.position,o.canvas.size,v.origin,t.getRadius(),e))return;break;case"inside":{const{dx:e,dy:i}=O(t.position,t.moveCenter),{x:s,y:o}=t.velocity;if(s<0&&e>t.moveCenter.radius||o<0&&i>t.moveCenter.radius||s>=0&&e<-t.moveCenter.radius||o>=0&&i<-t.moveCenter.radius)return;break}}o.particles.remove(t,void 0,!0)}}class $i{constructor(t){this.container=t,this.modes=["none"]}update(t,e,i,s){if(!this.modes.includes(s))return;if(t.options.move.distance.horizontal&&("left"===e||"right"===e)||t.options.move.distance.vertical&&("top"===e||"bottom"===e))return;const o=t.options.move.gravity,n=this.container,a=n.canvas.size,r=t.getRadius();if(o.enable){const i=t.position;(!o.inverse&&i.y>a.height+r&&"bottom"===e||o.inverse&&i.y<-r&&"top"===e)&&n.particles.remove(t)}else{if(t.velocity.y>0&&t.position.y<=a.height+r||t.velocity.y<0&&t.position.y>=-r||t.velocity.x>0&&t.position.x<=a.width+r||t.velocity.x<0&&t.position.x>=-r)return;tt(t.position,n.canvas.size,v.origin,r,e)||n.particles.remove(t)}}}class qi{constructor(t){this.container=t,this.modes=["out"]}update(t,e,i,s){if(!this.modes.includes(s))return;const o=this.container;switch(t.outType){case"inside":{const{x:e,y:i}=t.velocity,s=v.origin;s.length=t.moveCenter.radius,s.angle=t.velocity.angle+Math.PI,s.addTo(v.create(t.moveCenter));const{dx:n,dy:a}=O(t.position,s);if(e<=0&&n>=0||i<=0&&a>=0||e>=0&&n<=0||i>=0&&a<=0)return;t.position.x=Math.floor(C({min:0,max:o.canvas.size.width})),t.position.y=Math.floor(C({min:0,max:o.canvas.size.height}));const{dx:r,dy:c}=O(t.position,t.moveCenter);t.direction=Math.atan2(-c,-r),t.velocity.angle=t.direction;break}default:if(tt(t.position,o.canvas.size,v.origin,t.getRadius(),e))return;switch(t.outType){case"outside":{t.position.x=Math.floor(C({min:-t.moveCenter.radius,max:t.moveCenter.radius}))+t.moveCenter.x,t.position.y=Math.floor(C({min:-t.moveCenter.radius,max:t.moveCenter.radius}))+t.moveCenter.y;const{dx:e,dy:i}=O(t.position,t.moveCenter);t.moveCenter.radius&&(t.direction=Math.atan2(i,e),t.velocity.angle=t.direction);break}case"normal":{const i=t.options.move.warp,s=o.canvas.size,n={bottom:s.height+t.getRadius()+t.offset.y,left:-t.getRadius()-t.offset.x,right:s.width+t.getRadius()+t.offset.x,top:-t.getRadius()-t.offset.y},a=t.getRadius(),r=it(t.position,a);"right"===e&&r.left>s.width+t.offset.x?(t.position.x=n.left,t.initialPosition.x=t.position.x,i||(t.position.y=_()*s.height,t.initialPosition.y=t.position.y)):"left"===e&&r.right<-t.offset.x&&(t.position.x=n.right,t.initialPosition.x=t.position.x,i||(t.position.y=_()*s.height,t.initialPosition.y=t.position.y)),"bottom"===e&&r.top>s.height+t.offset.y?(i||(t.position.x=_()*s.width,t.initialPosition.x=t.position.x),t.position.y=n.top,t.initialPosition.y=t.position.y):"top"===e&&r.bottom<-t.offset.y&&(i||(t.position.x=_()*s.width,t.initialPosition.x=t.position.x),t.position.y=n.bottom,t.initialPosition.y=t.position.y);break}}}}}class Gi{constructor(t){this.container=t,this._updateOutMode=(t,e,i,s)=>{for(const o of this.updaters)o.update(t,s,e,i)},this.updaters=[new Vi(t),new Ui(t),new qi(t),new $i(t)]}init(){}isEnabled(t){return!t.destroyed&&!t.spawning}update(t,e){const i=t.options.move.outModes;this._updateOutMode(t,e,i.bottom??i.default,"bottom"),this._updateOutMode(t,e,i.left??i.default,"left"),this._updateOutMode(t,e,i.right??i.default,"right"),this._updateOutMode(t,e,i.top??i.default,"top")}}class Hi{init(t){const e=t.container,i=t.options.size.animation;i.enable&&(t.size.velocity=(t.retina.sizeAnimationSpeed??e.retina.sizeAnimationSpeed)/100*e.retina.reduceFactor,i.sync||(t.size.velocity*=_()))}isEnabled(t){return!t.destroyed&&!t.spawning&&t.size.enable&&((t.size.maxLoops??0)<=0||(t.size.maxLoops??0)>0&&(t.size.loops??0)<(t.size.maxLoops??0))}reset(t){t.size.loops=0}update(t,e){this.isEnabled(t)&&function(t,e){const i=t.size;if(t.destroyed||!i||!i.enable||(i.maxLoops??0)>0&&(i.loops??0)>(i.maxLoops??0))return;const s=(i.velocity??0)*e.factor,o=i.min,n=i.max,a=i.decay??1;if(i.time||(i.time=0),(i.delayTime??0)>0&&i.time<(i.delayTime??0)&&(i.time+=e.value),!((i.delayTime??0)>0&&i.time<(i.delayTime??0))){switch(i.status){case"increasing":i.value>=n?(i.status="decreasing",i.loops||(i.loops=0),i.loops++):i.value+=s;break;case"decreasing":i.value<=o?(i.status="increasing",i.loops||(i.loops=0),i.loops++):i.value-=s}i.velocity&&1!==a&&(i.velocity*=a),function(t,e,i,s){switch(t.options.size.animation.destroy){case"max":e>=s&&t.destroy();break;case"min":e<=i&&t.destroy()}}(t,i.value,o,n),t.destroyed||(i.value=z(i.value,o,n))}}(t,e)}}async function ji(t,e=!0){await async function(t,e=!0){await t.addMover("base",(()=>new Ri),e)}(t,!1),await async function(t,e=!0){await t.addShape("circle",new Fi,e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("color",(t=>new Ai(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("opacity",(t=>new Bi(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("outModes",(t=>new Gi(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("size",(()=>new Hi),e)}(t,!1),await t.refresh(e)}function Wi(t,e){if(!e.segments.length||!e.segments[0].values.length)return;const{context:i,radius:s}=t;i.moveTo(e.segments[0].values[0].x*s,e.segments[0].values[0].y*s);for(let t=0;t=0;t--){const o=e.segments[t];i.bezierCurveTo(-o.values[2].x*s,o.values[2].y*s,-o.values[1].x*s,o.values[1].y*s,-o.values[0].x*s,o.values[0].y*s)}}const Ni=.5,Xi={heart:{segments:[{values:[{x:0,y:Ni},{x:0,y:Ni},{x:Ni,y:0},{x:Ni,y:-Ni/2}]},{values:[{x:Ni,y:-Ni/2},{x:Ni,y:-Ni/2},{x:Ni,y:-Ni},{x:Ni/2,y:-Ni}]},{values:[{x:Ni/2,y:-Ni},{x:Ni/2,y:-Ni},{x:0,y:-Ni},{x:0,y:-Ni/2}]}]},diamond:{segments:[{values:[{x:0,y:Ni},{x:0,y:Ni},{x:.375,y:0},{x:.375,y:0}]},{values:[{x:.375,y:0},{x:.375,y:0},{x:0,y:-Ni},{x:0,y:-Ni}]}]},club:{segments:[{values:[{x:0,y:-Ni},{x:0,y:-Ni},{x:Ni/2,y:-Ni},{x:Ni/2,y:-Ni/2}]},{values:[{x:Ni/2,y:-Ni/2},{x:Ni/2,y:-Ni/2},{x:Ni,y:-Ni/2},{x:Ni,y:0}]},{values:[{x:Ni,y:0},{x:Ni,y:0},{x:Ni,y:Ni/2},{x:Ni/2,y:Ni/2}]},{values:[{x:Ni/2,y:Ni/2},{x:Ni/2,y:Ni/2},{x:Ni/8,y:Ni/2},{x:Ni/8,y:Ni/8}]},{values:[{x:Ni/8,y:Ni/8},{x:Ni/8,y:Ni/2},{x:Ni/2,y:Ni},{x:Ni/2,y:Ni}]},{values:[{x:Ni/2,y:Ni},{x:Ni/2,y:Ni},{x:0,y:Ni},{x:0,y:Ni}]}]},spade:{segments:[{values:[{x:0,y:-Ni},{x:0,y:-Ni},{x:Ni,y:-Ni/2},{x:Ni,y:0}]},{values:[{x:Ni,y:0},{x:Ni,y:0},{x:Ni,y:Ni/2},{x:Ni/2,y:Ni/2}]},{values:[{x:Ni/2,y:Ni/2},{x:Ni/2,y:Ni/2},{x:Ni/8,y:Ni/2},{x:Ni/8,y:Ni/8}]},{values:[{x:Ni/8,y:Ni/8},{x:Ni/8,y:Ni/2},{x:Ni/2,y:Ni},{x:Ni/2,y:Ni}]},{values:[{x:Ni/2,y:Ni},{x:Ni/2,y:Ni},{x:0,y:Ni},{x:0,y:Ni}]}]}};class Yi{draw(t){Wi(t,Xi.spade)}}class Zi{draw(t){Wi(t,Xi.heart)}}class Qi{draw(t){Wi(t,Xi.diamond)}}class Ji{draw(t){Wi(t,Xi.club)}}class Ki{constructor(){this.wait=!1}load(t){t&&(void 0!==t.count&&(this.count=t.count),void 0!==t.delay&&(this.delay=D(t.delay)),void 0!==t.duration&&(this.duration=D(t.duration)),void 0!==t.wait&&(this.wait=t.wait))}}class ts{constructor(){this.quantity=1,this.delay=.1}load(t){void 0!==t&&(void 0!==t.quantity&&(this.quantity=D(t.quantity)),void 0!==t.delay&&(this.delay=D(t.delay)))}}class es{constructor(){this.color=!1,this.opacity=!1}load(t){t&&(void 0!==t.color&&(this.color=t.color),void 0!==t.opacity&&(this.opacity=t.opacity))}}class is{constructor(){this.options={},this.replace=new es,this.type="square"}load(t){t&&(void 0!==t.options&&(this.options=st({},t.options??{})),this.replace.load(t.replace),void 0!==t.type&&(this.type=t.type))}}class ss{constructor(){this.mode="percent",this.height=0,this.width=0}load(t){void 0!==t&&(void 0!==t.mode&&(this.mode=t.mode),void 0!==t.height&&(this.height=t.height),void 0!==t.width&&(this.width=t.width))}}class os{constructor(){this.autoPlay=!0,this.fill=!0,this.life=new Ki,this.rate=new ts,this.shape=new is,this.startCount=0}load(t){t&&(void 0!==t.autoPlay&&(this.autoPlay=t.autoPlay),void 0!==t.size&&(this.size||(this.size=new ss),this.size.load(t.size)),void 0!==t.direction&&(this.direction=t.direction),this.domId=t.domId,void 0!==t.fill&&(this.fill=t.fill),this.life.load(t.life),this.name=t.name,this.particles=dt(t.particles,(t=>st({},t))),this.rate.load(t.rate),this.shape.load(t.shape),void 0!==t.position&&(this.position={},void 0!==t.position.x&&(this.position.x=D(t.position.x)),void 0!==t.position.y&&(this.position.y=D(t.position.y))),void 0!==t.spawnColor&&(void 0===this.spawnColor&&(this.spawnColor=new De),this.spawnColor.load(t.spawnColor)),void 0!==t.startCount&&(this.startCount=t.startCount))}}function ns(t,e){t.color?t.color.value=e:t.color={value:e}}class as{constructor(t,e,i,s,o){this.emitters=e,this.container=i,this._destroy=()=>{this._mutationObserver?.disconnect(),this._mutationObserver=void 0,this._resizeObserver?.disconnect(),this._resizeObserver=void 0,this.emitters.removeEmitter(this),this._engine.dispatchEvent("emitterDestroyed",{container:this.container,data:{emitter:this}})},this._prepareToDie=()=>{if(this._paused)return;const t=void 0!==this.options.life?.duration?P(this.options.life.duration):void 0;this.container.retina.reduceFactor&&(this._lifeCount>0||this._immortal)&&void 0!==t&&t>0&&(this._duration=1e3*t)},this._setColorAnimation=(t,e,i)=>{const s=this.container;if(!t.enable)return e;const o=C(t.offset),n=1e3*P(this.options.rate.delay)/s.retina.reduceFactor;return(e+P(t.speed??0)*s.fpsLimit/n+3.6*o)%i},this._engine=t,this._currentDuration=0,this._currentEmitDelay=0,this._currentSpawnDelay=0,this._initialPosition=o,s instanceof os?this.options=s:(this.options=new os,this.options.load(s)),this._spawnDelay=1e3*P(this.options.life.delay??0)/this.container.retina.reduceFactor,this.position=this._initialPosition??this._calcPosition(),this.name=this.options.name,this.fill=this.options.fill,this._firstSpawn=!this.options.life.wait,this._startParticlesAdded=!1;let n=st({},this.options.particles);if(n??={},n.move??={},n.move.direction??=this.options.direction,this.options.spawnColor&&(this.spawnColor=It(this.options.spawnColor)),this._paused=!this.options.autoPlay,this._particlesOptions=n,this._size=this._calcSize(),this.size=yt(this._size,this.container.canvas.size),this._lifeCount=this.options.life.count??-1,this._immortal=this._lifeCount<=0,this.options.domId){const t=document.getElementById(this.options.domId);t&&(this._mutationObserver=new MutationObserver((()=>{this.resize()})),this._resizeObserver=new ResizeObserver((()=>{this.resize()})),this._mutationObserver.observe(t,{attributes:!0,attributeFilter:["style","width","height"]}),this._resizeObserver.observe(t))}const a=this.options.shape,r=this._engine.emitterShapeManager?.getShapeGenerator(a.type);r&&(this._shape=r.generate(this.position,this.size,this.fill,a.options)),this._engine.dispatchEvent("emitterCreated",{container:i,data:{emitter:this}}),this.play()}externalPause(){this._paused=!0,this.pause()}externalPlay(){this._paused=!1,this.play()}async init(){await(this._shape?.init())}pause(){this._paused||delete this._emitDelay}play(){if(!this._paused&&this.container.retina.reduceFactor&&(this._lifeCount>0||this._immortal||!this.options.life.count)&&(this._firstSpawn||this._currentSpawnDelay>=(this._spawnDelay??0))){if(void 0===this._emitDelay){const t=P(this.options.rate.delay);this._emitDelay=1e3*t/this.container.retina.reduceFactor}(this._lifeCount>0||this._immortal)&&this._prepareToDie()}}resize(){const t=this._initialPosition;this.position=t&&tt(t,this.container.canvas.size,v.origin)?t:this._calcPosition(),this._size=this._calcSize(),this.size=yt(this._size,this.container.canvas.size),this._shape?.resize(this.position,this.size)}async update(t){this._paused||(this._firstSpawn&&(this._firstSpawn=!1,this._currentSpawnDelay=this._spawnDelay??0,this._currentEmitDelay=this._emitDelay??0),this._startParticlesAdded||(this._startParticlesAdded=!0,await this._emitParticles(this.options.startCount)),void 0!==this._duration&&(this._currentDuration+=t.value,this._currentDuration>=this._duration&&(this.pause(),void 0!==this._spawnDelay&&delete this._spawnDelay,this._immortal||this._lifeCount--,this._lifeCount>0||this._immortal?(this.position=this._calcPosition(),this._shape?.resize(this.position,this.size),this._spawnDelay=1e3*P(this.options.life.delay??0)/this.container.retina.reduceFactor):this._destroy(),this._currentDuration-=this._duration,delete this._duration)),void 0!==this._spawnDelay&&(this._currentSpawnDelay+=t.value,this._currentSpawnDelay>=this._spawnDelay&&(this._engine.dispatchEvent("emitterPlay",{container:this.container}),this.play(),this._currentSpawnDelay-=this._currentSpawnDelay,delete this._spawnDelay)),void 0!==this._emitDelay&&(this._currentEmitDelay+=t.value,this._currentEmitDelay>=this._emitDelay&&(this._emit(),this._currentEmitDelay-=this._emitDelay)))}_calcPosition(){if(this.options.domId){const t=this.container,e=document.getElementById(this.options.domId);if(e){const i=e.getBoundingClientRect();return{x:(i.x+i.width/2)*t.retina.pixelRatio,y:(i.y+i.height/2)*t.retina.pixelRatio}}}return A({size:this.container.canvas.size,position:this.options.position})}_calcSize(){const t=this.container;if(this.options.domId){const e=document.getElementById(this.options.domId);if(e){const i=e.getBoundingClientRect();return{width:i.width*t.retina.pixelRatio,height:i.height*t.retina.pixelRatio,mode:"precise"}}}return this.options.size??(()=>{const t=new ss;return t.load({height:0,mode:"percent",width:0}),t})()}async _emit(){if(this._paused)return;const t=P(this.options.rate.quantity);await this._emitParticles(t)}async _emitParticles(t){const e=ut(this._particlesOptions);for(let i=0;ivoid 0===t||bt(t)?this.array[t||0]:this.array.find((e=>e.name===t)),e.addEmitter=async(t,e)=>this.addEmitter(t,e),e.removeEmitter=t=>{const i=e.getEmitter(t);i&&this.removeEmitter(i)},e.playEmitter=t=>{const i=e.getEmitter(t);i&&i.externalPlay()},e.pauseEmitter=t=>{const i=e.getEmitter(t);i&&i.externalPause()}}async addEmitter(t,e){const i=new os;i.load(t);const s=new as(this._engine,this,this.container,i,e);return await s.init(),this.array.push(s),s}handleClickMode(t){const e=this.emitters,i=this.interactivityEmitters;if("emitter"!==t)return;let s;if(i&&zt(i.value))if(i.value.length>0&&i.random.enable){s=[];const t=[];for(let e=0;e{this.addEmitter(t,n)}))}async init(){if(this.emitters=this.container.actualOptions.emitters,this.interactivityEmitters=this.container.actualOptions.interactivity.modes.emitters,this.emitters)if(zt(this.emitters))for(const t of this.emitters)await this.addEmitter(t);else await this.addEmitter(this.emitters)}pause(){for(const t of this.array)t.pause()}play(){for(const t of this.array)t.play()}removeEmitter(t){const e=this.array.indexOf(t);e>=0&&this.array.splice(e,1)}resize(){for(const t of this.array)t.resize()}stop(){this.array=[]}async update(t){for(const e of this.array)await e.update(t)}}const cs=new Map;class ls{constructor(t){this._engine=t}addShapeGenerator(t,e){this.getShapeGenerator(t)||cs.set(t,e)}getShapeGenerator(t){return cs.get(t)}getSupportedShapeGenerators(){return cs.keys()}}class hs{constructor(t){this._engine=t,this.id="emitters"}getPlugin(t){return new rs(this._engine,t)}loadOptions(t,e){if(!this.needsPlugin(t)&&!this.needsPlugin(e))return;e?.emitters&&(t.emitters=dt(e.emitters,(t=>{const e=new os;return e.load(t),e})));const i=e?.interactivity?.modes?.emitters;if(i)if(zt(i))t.interactivity.modes.emitters={random:{count:1,enable:!0},value:i.map((t=>{const e=new os;return e.load(t),e}))};else{const e=i;if(void 0!==e.value)if(zt(e.value))t.interactivity.modes.emitters={random:{count:e.random.count??1,enable:e.random.enable??!1},value:e.value.map((t=>{const e=new os;return e.load(t),e}))};else{const i=new os;i.load(e.value),t.interactivity.modes.emitters={random:{count:e.random.count??1,enable:e.random.enable??!1},value:i}}else{(t.interactivity.modes.emitters={random:{count:1,enable:!1},value:new os}).value.load(i)}}}needsPlugin(t){if(!t)return!1;const e=t.emitters;return zt(e)&&!!e.length||void 0!==e||!!t.interactivity?.events?.onClick?.mode&&Z("emitter",t.interactivity.events.onClick.mode)}}const ds=["emoji"],us='"Twemoji Mozilla", Apple Color Emoji, "Segoe UI Emoji", "Noto Color Emoji", "EmojiOne Color"';class ps{constructor(){this._emojiShapeDict=new Map}destroy(){for(const[t,e]of this._emojiShapeDict)e instanceof ImageBitmap&&e?.close(),this._emojiShapeDict.delete(t)}draw(t){const{context:e,particle:i,radius:s,opacity:o}=t,n=i.emojiData;n&&(e.globalAlpha=o,e.drawImage(n,-s,-s,2*s,2*s),e.globalAlpha=1)}async init(t){const e=t.actualOptions;if(ds.find((t=>Z(t,e.particles.shape.type)))){const t=[Q(us)],i=ds.map((t=>e.particles.shape.options[t])).find((t=>!!t));i&&dt(i,(e=>{e.font&&t.push(Q(e.font))})),await Promise.all(t)}}particleDestroy(t){delete t.emojiData}particleInit(t,e){const i=e.shapeData;if(!i?.value)return;const s=ut(i.value,e.randomIndexData),o=i.font??us;if(!s)return;const n=`${s}_${o}`,a=this._emojiShapeDict.get(n);if(a)return void(e.emojiData=a);const r=2*k(e.size.value);let c;if("undefined"!=typeof OffscreenCanvas){const t=new OffscreenCanvas(r,r),i=t.getContext("2d");if(!i)return;i.font=`400 ${2*k(e.size.value)}px ${o}`,i.textBaseline="middle",i.textAlign="center",i.fillText(s,k(e.size.value),k(e.size.value)),c=t.transferToImageBitmap()}else{const t=document.createElement("canvas");t.width=r,t.height=r;const i=t.getContext("2d");if(!i)return;i.font=`400 ${2*k(e.size.value)}px ${o}`,i.textBaseline="middle",i.textAlign="center",i.fillText(s,k(e.size.value),k(e.size.value)),c=t}this._emojiShapeDict.set(n,c),e.emojiData=c}}class fs{draw(t){const{context:e,radius:i}=t,s=2*i,o=.5*i,n=i+o,a=-i,r=-i;e.moveTo(a,r+i/2),e.quadraticCurveTo(a,r,a+o,r),e.quadraticCurveTo(a+i,r,a+i,r+o),e.quadraticCurveTo(a+i,r,a+n,r),e.quadraticCurveTo(a+s,r,a+s,r+o),e.quadraticCurveTo(a+s,r+i,a+n,r+n),e.lineTo(a+i,r+s),e.lineTo(a+o,r+n),e.quadraticCurveTo(a,r+i,a,r+o)}}const ms=[0,4,2,1],vs=[8,8,4,2];class ys{constructor(t){this.pos=0,this.data=new Uint8ClampedArray(t)}getString(t){const e=this.data.slice(this.pos,this.pos+t);return this.pos+=e.length,e.reduce(((t,e)=>t+String.fromCharCode(e)),"")}nextByte(){return this.data[this.pos++]}nextTwoBytes(){return this.pos+=2,this.data[this.pos-2]+(this.data[this.pos-1]<<8)}readSubBlocks(){let t="",e=0;do{e=this.data[this.pos++];for(let i=e;--i>=0;t+=String.fromCharCode(this.data[this.pos++]));}while(0!==e);return t}readSubBlocksBin(){let t=0,e=0;for(let i=0;0!==(t=this.data[this.pos+i]);i+=t+1)e+=t;const i=new Uint8Array(e);for(let e=0;0!==(t=this.data[this.pos++]);)for(let s=t;--s>=0;i[e++]=this.data[this.pos++]);return i}skipSubBlocks(){for(;0!==this.data[this.pos];this.pos+=this.data[this.pos]+1);this.pos++}}function gs(t,e){const i=[];for(let s=0;s>>3;const h=1<<1+(7&r);c&&(a.localColorTable=gs(t,h));const d=t=>{const{r:s,g:n,b:r}=(c?a.localColorTable:e.globalColorTable)[t];return{r:s,g:n,b:r,a:t===o(null)?i?~~((s+n+r)/3):0:255}},u=(()=>{try{return new ImageData(a.width,a.height,{colorSpace:"srgb"})}catch(t){if(t instanceof DOMException&&"IndexSizeError"===t.name)return null;throw t}})();if(null==u)throw new EvalError("GIF frame size is to large");const p=t.nextByte(),f=t.readSubBlocksBin(),m=1<{const i=t>>>3,s=7&t;return(f[i]+(f[i+1]<<8)+(f[i+2]<<16)&(1<>>s};if(l){for(let i=0,o=p+1,r=0,c=[[0]],l=0;l<4;l++){if(ms[l]=c.length?c.push(c[s].concat(c[s][0])):s!==m&&c.push(c[s].concat(c[i][0]));for(let s=0;s=a.height))break}n?.(t.pos/(t.data.length-1),s(!1)+1,u,{x:a.left,y:a.top},{width:e.width,height:e.height})}a.image=u,a.bitmap=await createImageBitmap(u)}else{for(let t=0,e=p+1,i=0,s=[[0]],o=-4;;){const n=t;if(t=v(i,e),i+=e,t===m){e=p+1,s.length=m+2;for(let t=0;t=s.length?s.push(s[n].concat(s[n][0])):n!==m&&s.push(s[n].concat(s[t][0]));for(let e=0;e=1<>>5,o.disposalMethod=(28&n)>>>2,o.userInputDelayFlag=2==(2&n);const a=1==(1&n);o.delayTime=10*t.nextTwoBytes();const r=t.nextByte();a&&s(r),t.pos++;break}case 255:{t.pos++;const i={identifier:t.getString(8),authenticationCode:t.getString(3),data:t.readSubBlocksBin()};e.applicationExtensions.push(i);break}case 254:e.comments.push([i(!1),t.readSubBlocks()]);break;case 1:if(0===e.globalColorTable.length)throw new EvalError("plain text extension without global color table");t.pos++,e.frames[i(!1)].plainTextData={left:t.nextTwoBytes(),top:t.nextTwoBytes(),width:t.nextTwoBytes(),height:t.nextTwoBytes(),charSize:{width:t.nextTwoBytes(),height:t.nextTwoBytes()},foregroundColor:t.nextByte(),backgroundColor:t.nextByte(),text:t.readSubBlocks()};break;default:t.skipSubBlocks()}}(t,e,s,o);break;default:throw new EvalError("undefined block found")}return!1}const bs=/(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d.]+%?\))|currentcolor/gi;async function xs(t){return new Promise((e=>{t.loading=!0;const i=new Image;t.element=i,i.addEventListener("load",(()=>{t.loading=!1,e()})),i.addEventListener("error",(()=>{t.element=void 0,t.error=!0,t.loading=!1,G().error(`${f} loading image: ${t.source}`),e()})),i.src=t.source}))}async function _s(t){if("gif"===t.type){t.loading=!0;try{t.gifData=await async function(t,e,i){i||(i=!1);const s=await fetch(t);if(!s.ok&&404===s.status)throw new EvalError("file not found");const o=await s.arrayBuffer(),n={width:0,height:0,totalTime:0,colorRes:0,pixelAspectRatio:0,frames:[],sortFlag:!1,globalColorTable:[],backgroundImage:new ImageData(1,1,{colorSpace:"srgb"}),comments:[],applicationExtensions:[]},a=new ys(new Uint8ClampedArray(o));if("GIF89a"!==a.getString(6))throw new Error("not a supported GIF file");n.width=a.nextTwoBytes(),n.height=a.nextTwoBytes();const r=a.nextByte(),c=128==(128&r);n.colorRes=(112&r)>>>4,n.sortFlag=8==(8&r);const l=1<<1+(7&r),h=a.nextByte();n.pixelAspectRatio=a.nextByte(),0!==n.pixelAspectRatio&&(n.pixelAspectRatio=(n.pixelAspectRatio+15)/64),c&&(n.globalColorTable=gs(a,l));const d=(()=>{try{return new ImageData(n.width,n.height,{colorSpace:"srgb"})}catch(t){if(t instanceof DOMException&&"IndexSizeError"===t.name)return null;throw t}})();if(null==d)throw new Error("GIF frame size is to large");const{r:u,g:p,b:f}=n.globalColorTable[h];d.data.set(c?[u,p,f,255]:[0,0,0,0]);for(let t=4;t(t&&(v=!0),m),w=t=>(null!=t&&(y=t),y);try{do{v&&(n.frames.push({left:0,top:0,width:0,height:0,disposalMethod:0,image:new ImageData(1,1,{colorSpace:"srgb"}),plainTextData:null,userInputDelayFlag:!1,delayTime:0,sortFlag:!1,localColorTable:[],reserved:0,GCreserved:0}),m++,y=-1,v=!1)}while(!await ws(a,n,i,g,w,e));n.frames.length--;for(const t of n.frames){if(t.userInputDelayFlag&&0===t.delayTime){n.totalTime=1/0;break}n.totalTime+=t.delayTime}return n}catch(t){if(t instanceof EvalError)throw new Error(`error while parsing frame ${m} "${t.message}"`);throw t}}(t.source),t.gifLoopCount=function(t){for(const e of t.applicationExtensions)if(e.identifier+e.authenticationCode==="NETSCAPE2.0")return e.data[1]+(e.data[2]<<8);return NaN}(t.gifData)??0,0===t.gifLoopCount&&(t.gifLoopCount=1/0)}catch{t.error=!0}t.loading=!1}else await xs(t)}async function zs(t){if("svg"!==t.type)return void await xs(t);t.loading=!0;const e=await fetch(t.source);e.ok?t.svgData=await e.text():(G().error(`${f} Image not found`),t.error=!0),t.loading=!1}function Ms(t,e,i,s){const o=function(t,e,i){const{svgData:s}=t;if(!s)return"";const o=Ut(e,i);if(s.includes("fill"))return s.replace(bs,(()=>o));const n=s.indexOf(">");return`${s.substring(0,n)} fill="${o}"${s.substring(n)}`}(t,i,s.opacity?.value??1),n={color:i,gif:e.gif,data:{...t,svgData:o},loaded:!1,ratio:e.width/e.height,replaceColor:e.replaceColor,source:e.src};return new Promise((e=>{const i=new Blob([o],{type:"image/svg+xml"}),s=URL||window.URL||window.webkitURL||window,a=s.createObjectURL(i),r=new Image;r.addEventListener("load",(()=>{n.loaded=!0,n.element=r,e(n),s.revokeObjectURL(a)})),r.addEventListener("error",(async()=>{s.revokeObjectURL(a);const i={...t,error:!1,loading:!0};await xs(i),n.loaded=!0,n.element=i.element,e(n)})),r.src=a}))}class Cs{constructor(t){this.loadImageShape=async t=>{if(!this._engine.loadImage)throw new Error(`${f} image shape not initialized`);await this._engine.loadImage({gif:t.gif,name:t.name,replaceColor:t.replaceColor??!1,src:t.src})},this._engine=t}addImage(t){this._engine.images||(this._engine.images=[]),this._engine.images.push(t)}draw(t){const{context:e,radius:i,particle:s,opacity:o,delta:n}=t,a=s.image,r=a?.element;if(a){if(e.globalAlpha=o,a.gif&&a.gifData){const t=new OffscreenCanvas(a.gifData.width,a.gifData.height),o=t.getContext("2d");if(!o)throw new Error("could not create offscreen canvas context");o.imageSmoothingQuality="low",o.imageSmoothingEnabled=!1,o.clearRect(0,0,t.width,t.height),void 0===s.gifLoopCount&&(s.gifLoopCount=a.gifLoopCount??0);let r=s.gifFrame??0;const c={x:.5*-a.gifData.width,y:.5*-a.gifData.height},l=a.gifData.frames[r];if(void 0===s.gifTime&&(s.gifTime=0),!l.bitmap)return;switch(e.scale(i/a.gifData.width,i/a.gifData.height),l.disposalMethod){case 4:case 5:case 6:case 7:case 0:o.drawImage(l.bitmap,l.left,l.top),e.drawImage(t,c.x,c.y),o.clearRect(0,0,t.width,t.height);break;case 1:o.drawImage(l.bitmap,l.left,l.top),e.drawImage(t,c.x,c.y);break;case 2:o.drawImage(l.bitmap,l.left,l.top),e.drawImage(t,c.x,c.y),o.clearRect(0,0,t.width,t.height),0===a.gifData.globalColorTable.length?o.putImageData(a.gifData.frames[0].image,c.x+l.left,c.y+l.top):o.putImageData(a.gifData.backgroundImage,c.x,c.y);break;case 3:{const i=o.getImageData(0,0,t.width,t.height);o.drawImage(l.bitmap,l.left,l.top),e.drawImage(t,c.x,c.y),o.clearRect(0,0,t.width,t.height),o.putImageData(i,0,0)}}if(s.gifTime+=n.value,s.gifTime>l.delayTime){if(s.gifTime-=l.delayTime,++r>=a.gifData.frames.length){if(--s.gifLoopCount<=0)return;r=0,o.clearRect(0,0,t.width,t.height)}s.gifFrame=r}e.scale(a.gifData.width/i,a.gifData.height/i)}else if(r){const t=a.ratio,s={x:-i,y:-i},o=2*i;e.drawImage(r,s.x,s.y,o,o/t)}e.globalAlpha=1}}getSidesCount(){return 12}async init(t){const e=t.actualOptions;if(e.preload&&this._engine.loadImage)for(const t of e.preload)await this._engine.loadImage(t)}loadShape(t){if("image"!==t.shape&&"images"!==t.shape)return;this._engine.images||(this._engine.images=[]);const e=t.shapeData;if(!e)return;this._engine.images.find((t=>t.name===e.name||t.source===e.src))||this.loadImageShape(e).then((()=>{this.loadShape(t)}))}particleInit(t,e){if("image"!==e.shape&&"images"!==e.shape)return;this._engine.images||(this._engine.images=[]);const i=this._engine.images,s=e.shapeData;if(!s)return;const o=e.getFillColor(),n=i.find((t=>t.name===s.name||t.source===s.src));if(!n)return;const a=s.replaceColor??n.replaceColor;n.loading?setTimeout((()=>{this.particleInit(t,e)})):(async()=>{let t;t=n.svgData&&o?await Ms(n,s,o,e):{color:o,data:n,element:n.element,gif:n.gif,gifData:n.gifData,gifLoopCount:n.gifLoopCount,loaded:!0,ratio:s.width&&s.height?s.width/s.height:n.ratio??1,replaceColor:a,source:s.src},t.ratio||(t.ratio=1);const i={image:t,fill:s.fill??e.shapeFill,close:s.close??e.shapeClose};e.image=i.image,e.shapeFill=i.fill,e.shapeClose=i.close})()}}class Ps{constructor(){this.src="",this.gif=!1}load(t){t&&(void 0!==t.gif&&(this.gif=t.gif),void 0!==t.height&&(this.height=t.height),void 0!==t.name&&(this.name=t.name),void 0!==t.replaceColor&&(this.replaceColor=t.replaceColor),void 0!==t.src&&(this.src=t.src),void 0!==t.width&&(this.width=t.width))}}class Ss{constructor(t){this.id="imagePreloader",this._engine=t}getPlugin(){return{}}loadOptions(t,e){if(!e||!e.preload)return;t.preload||(t.preload=[]);const i=t.preload;for(const t of e.preload){const e=i.find((e=>e.name===t.name||e.src===t.src));if(e)e.load(t);else{const e=new Ps;e.load(t),i.push(e)}}}needsPlugin(){return!0}}async function ks(t,e=!0){!function(t){t.loadImage||(t.loadImage=async e=>{if(!e.name&&!e.src)throw new Error(`${f} no image source provided`);if(t.images||(t.images=[]),!t.images.find((t=>t.name===e.name||t.source===e.src)))try{const i={gif:e.gif??!1,name:e.name??e.src,source:e.src,type:e.src.substring(e.src.length-3),error:!1,loading:!0,replaceColor:e.replaceColor,ratio:e.width&&e.height?e.width/e.height:void 0};t.images.push(i);const s=e.gif?_s:e.replaceColor?zs:xs;await s(i)}catch{throw new Error(`${f} ${e.name??e.src} not found`)}})}(t);const i=new Ss(t);await t.addPlugin(i,e),await t.addShape(["image","images"],new Cs(t),e)}class Ds extends Ie{constructor(){super(),this.sync=!1}load(t){t&&(super.load(t),void 0!==t.sync&&(this.sync=t.sync))}}class Os extends Ie{constructor(){super(),this.sync=!1}load(t){t&&(super.load(t),void 0!==t.sync&&(this.sync=t.sync))}}class Ts{constructor(){this.count=0,this.delay=new Ds,this.duration=new Os}load(t){t&&(void 0!==t.count&&(this.count=t.count),this.delay.load(t.delay),this.duration.load(t.duration))}}class Is{constructor(t){this.container=t}init(t){const e=this.container,i=t.options.life;i&&(t.life={delay:e.retina.reduceFactor?P(i.delay.value)*(i.delay.sync?1:_())/e.retina.reduceFactor*1e3:0,delayTime:0,duration:e.retina.reduceFactor?P(i.duration.value)*(i.duration.sync?1:_())/e.retina.reduceFactor*1e3:0,time:0,count:i.count},t.life.duration<=0&&(t.life.duration=-1),t.life.count<=0&&(t.life.count=-1),t.life&&(t.spawning=t.life.delay>0))}isEnabled(t){return!t.destroyed}loadOptions(t,...e){t.life||(t.life=new Ts);for(const i of e)t.life.load(i?.life)}update(t,e){if(!this.isEnabled(t)||!t.life)return;const i=t.life;let s=!1;if(t.spawning){if(i.delayTime+=e.value,!(i.delayTime>=t.life.delay))return;s=!0,t.spawning=!1,i.delayTime=0,i.time=0}if(-1===i.duration)return;if(t.spawning)return;if(s?i.time=0:i.time+=e.value,i.time0&&t.life.count--,0===t.life.count)return void t.destroy();const o=this.container.canvas.size,n=D(0,o.width),a=D(0,o.width);t.position.x=C(n),t.position.y=C(a),t.spawning=!0,i.delayTime=0,i.time=0,t.reset();const r=t.options.life;r&&(i.delay=1e3*P(r.delay.value),i.duration=1e3*P(r.duration.value))}}class Es{constructor(){this.factor=4,this.value=!0}load(t){t&&(void 0!==t.factor&&(this.factor=t.factor),void 0!==t.value&&(this.value=t.value))}}class Rs{constructor(){this.disable=!1,this.reduce=new Es}load(t){t&&(void 0!==t.disable&&(this.disable=t.disable),this.reduce.load(t.reduce))}}class Fs{constructor(t,e){this._handleMotionChange=t=>{const e=this._container,i=e.actualOptions.motion;i&&(e.retina.reduceFactor=t.matches?i.disable?0:i.reduce.value?1/i.reduce.factor:1:1)},this._container=t,this._engine=e}async init(){const t=this._container,e=t.actualOptions.motion;if(!e||!e.disable&&!e.reduce.value)return void(t.retina.reduceFactor=1);const i=N("(prefers-reduced-motion: reduce)");if(!i)return void(t.retina.reduceFactor=1);this._handleMotionChange(i);const s=async()=>{this._handleMotionChange(i);try{await t.refresh()}catch{}};void 0!==i.addEventListener?i.addEventListener("change",s):void 0!==i.addListener&&i.addListener(s)}}class Ls{constructor(t){this.id="motion",this._engine=t}getPlugin(t){return new Fs(t,this._engine)}loadOptions(t,e){if(!this.needsPlugin())return;let i=t.motion;i?.load||(t.motion=i=new Rs),i.load(e?.motion)}needsPlugin(){return!0}}class As{draw(t){const{context:e,particle:i,radius:s}=t,o=this.getCenter(i,s),n=this.getSidesData(i,s),a=n.count.numerator*n.count.denominator,r=n.count.numerator/n.count.denominator,c=180*(r-2)/r,l=Math.PI-Math.PI*c/180;if(e){e.beginPath(),e.translate(o.x,o.y),e.moveTo(0,0);for(let t=0;t=.5?"darken":"enlighten";t.roll.alter={type:i,value:P("darken"===i?e.darken.value:e.enlighten.value)}}else e.darken.enable?t.roll.alter={type:"darken",value:P(e.darken.value)}:e.enlighten.enable&&(t.roll.alter={type:"enlighten",value:P(e.enlighten.value)});else t.roll={enable:!1,horizontal:!1,vertical:!1,angle:0,speed:0}}(t)}isEnabled(t){const e=t.options.roll;return!t.destroyed&&!t.spawning&&!!e?.enable}loadOptions(t,...e){t.roll||(t.roll=new qs);for(const i of e)t.roll.load(i?.roll)}update(t,e){this.isEnabled(t)&&function(t,e){const i=t.options.roll,s=t.roll;if(!s||!i?.enable)return;const o=s.speed*e.factor,n=2*Math.PI;s.angle+=o,s.angle>n&&(s.angle-=n)}(t,e)}}class Hs{constructor(){this.enable=!1,this.speed=0,this.decay=0,this.sync=!1}load(t){t&&(void 0!==t.enable&&(this.enable=t.enable),void 0!==t.speed&&(this.speed=D(t.speed)),void 0!==t.decay&&(this.decay=D(t.decay)),void 0!==t.sync&&(this.sync=t.sync))}}class js extends Ie{constructor(){super(),this.animation=new Hs,this.direction="clockwise",this.path=!1,this.value=0}load(t){t&&(super.load(t),void 0!==t.direction&&(this.direction=t.direction),this.animation.load(t.animation),void 0!==t.path&&(this.path=t.path))}}class Ws{constructor(t){this.container=t}init(t){const e=t.options.rotate;if(!e)return;t.rotate={enable:e.animation.enable,value:P(e.value)*Math.PI/180},t.pathRotation=e.path;let i=e.direction;if("random"===i){i=Math.floor(2*_())>0?"counter-clockwise":"clockwise"}switch(i){case"counter-clockwise":case"counterClockwise":t.rotate.status="decreasing";break;case"clockwise":t.rotate.status="increasing"}const s=e.animation;s.enable&&(t.rotate.decay=1-P(s.decay),t.rotate.velocity=P(s.speed)/360*this.container.retina.reduceFactor,s.sync||(t.rotate.velocity*=_())),t.rotation=t.rotate.value}isEnabled(t){const e=t.options.rotate;return!!e&&(!t.destroyed&&!t.spawning&&e.animation.enable&&!e.path)}loadOptions(t,...e){t.rotate||(t.rotate=new js);for(const i of e)t.rotate.load(i?.rotate)}update(t,e){this.isEnabled(t)&&(!function(t,e){const i=t.rotate,s=t.options.rotate;if(!i||!s)return;const o=s.animation,n=(i.velocity??0)*e.factor,a=2*Math.PI,r=i.decay??1;o.enable&&("increasing"===i.status?(i.value+=n,i.value>a&&(i.value-=a)):(i.value-=n,i.value<0&&(i.value+=a)),i.velocity&&1!==r&&(i.velocity*=r))}(t,e),t.rotation=t.rotate?.value??0)}}const Ns=Math.sqrt(2);class Xs{draw(t){const{context:e,radius:i}=t,s=i/Ns,o=2*s;e.rect(-s,-s,o,o)}getSidesCount(){return 4}}class Ys{draw(t){const{context:e,particle:i,radius:s}=t,o=i.sides,n=i.starInset??2;e.moveTo(0,0-s);for(let t=0;t=.5?1:-1,cosDirection:_()>=.5?1:-1};let i=e.direction;if("random"===i){i=Math.floor(2*_())>0?"counter-clockwise":"clockwise"}switch(i){case"counter-clockwise":case"counterClockwise":t.tilt.status="decreasing";break;case"clockwise":t.tilt.status="increasing"}const s=t.options.tilt?.animation;s?.enable&&(t.tilt.decay=1-P(s.decay),t.tilt.velocity=P(s.speed)/360*this.container.retina.reduceFactor,s.sync||(t.tilt.velocity*=_()))}isEnabled(t){const e=t.options.tilt?.animation;return!t.destroyed&&!t.spawning&&!!e?.enable}loadOptions(t,...e){t.tilt||(t.tilt=new Qs);for(const i of e)t.tilt.load(i?.tilt)}update(t,e){this.isEnabled(t)&&function(t,e){if(!t.tilt||!t.options.tilt)return;const i=t.options.tilt.animation,s=(t.tilt.velocity??0)*e.factor,o=2*Math.PI,n=t.tilt.decay??1;i.enable&&("increasing"===t.tilt.status?(t.tilt.value+=s,t.tilt.value>o&&(t.tilt.value-=o)):(t.tilt.value-=s,t.tilt.value<0&&(t.tilt.value+=o)),t.tilt.velocity&&1!==n&&(t.tilt.velocity*=n))}(t,e)}}class Ks{constructor(){this.angle=50,this.move=10}load(t){t&&(void 0!==t.angle&&(this.angle=D(t.angle)),void 0!==t.move&&(this.move=D(t.move)))}}class to{constructor(){this.distance=5,this.enable=!1,this.speed=new Ks}load(t){if(t&&(void 0!==t.distance&&(this.distance=D(t.distance)),void 0!==t.enable&&(this.enable=t.enable),void 0!==t.speed))if(bt(t.speed))this.speed.load({angle:t.speed});else{const e=t.speed;void 0!==e.min?this.speed.load({angle:e}):this.speed.load(t.speed)}}}class eo{constructor(t){this.container=t}init(t){const e=t.options.wobble;t.wobble=e?.enable?{angle:_()*Math.PI*2,angleSpeed:P(e.speed.angle)/360,moveSpeed:P(e.speed.move)/10}:{angle:0,angleSpeed:0,moveSpeed:0},t.retina.wobbleDistance=P(e?.distance??0)*this.container.retina.pixelRatio}isEnabled(t){return!t.destroyed&&!t.spawning&&!!t.options.wobble?.enable}loadOptions(t,...e){t.wobble||(t.wobble=new to);for(const i of e)t.wobble.load(i?.wobble)}update(t,e){this.isEnabled(t)&&function(t,e){const{wobble:i}=t.options,{wobble:s}=t;if(!i?.enable||!s)return;const o=s.angleSpeed*e.factor,n=s.moveSpeed*e.factor*((t.retina.wobbleDistance??0)*e.factor)/(1e3/60),a=2*Math.PI,{position:r}=t;s.angle+=o,s.angle>a&&(s.angle-=a),r.x+=n*Math.cos(s.angle),r.y+=n*Math.abs(Math.sin(s.angle))}(t,e)}}let io=!1,so=!1;const oo=new Map;async function no(t){if(!io){if(so)return new Promise((t=>{const e=setInterval((()=>{io&&(clearInterval(e),t())}),100)}));so=!0,await async function(t,e=!0){t.emitterShapeManager||(t.emitterShapeManager=new ls(t)),t.addEmitterShapeGenerator||(t.addEmitterShapeGenerator=(e,i)=>{t.emitterShapeManager?.addShapeGenerator(e,i)});const i=new hs(t);await t.addPlugin(i,e)}(t,!1),await async function(t,e=!0){await t.addPlugin(new Ls(t),e)}(t,!1),await async function(t,e=!0){await t.addShape(["spade","spades"],new Yi,e),await t.addShape(["heart","hearts"],new Zi,e),await t.addShape(["diamond","diamonds"],new Qi,e),await t.addShape(["club","clubs"],new Ji,e)}(t,!1),await async function(t,e=!0){await t.addShape("heart",new fs,e)}(t,!1),await ks(t,!1),await Us(t,!1),await async function(t,e=!0){await t.addShape(["edge","square"],new Xs,e)}(t,!1),await async function(t,e=!0){await t.addShape("star",new Ys,e)}(t,!1),await async function(t,e=!0){await t.addShape(ds,new ps,e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("rotate",(t=>new Ws(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("life",(t=>new Is(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("roll",(()=>new Gs),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("tilt",(t=>new Js(t)),e)}(t,!1),await async function(t,e=!0){await t.addParticleUpdater("wobble",(t=>new eo(t)),e)}(t,!1),await ji(t),so=!1,io=!0}}async function ao(t){const e=new Ii;let i;e.load(t.options);const s=1e3*e.ticks/432e3;if(oo.has(t.id)&&(i=oo.get(t.id),i&&!i.destroyed)){const t=i;if(t.addEmitter)return void t.addEmitter({startCount:e.count,position:e.position,size:{width:0,height:0},rate:{delay:0,quantity:0},life:{duration:.1,count:1},particles:{color:{value:e.colors},shape:{type:e.shapes,options:e.shapeOptions},life:{count:1},opacity:{value:{min:0,max:1},animation:{enable:!0,sync:!0,speed:s,startValue:"max",destroy:"min"}},size:{value:5*e.scalar},move:{angle:{value:e.spread,offset:0},drift:{min:-e.drift,max:e.drift},gravity:{acceleration:9.81*e.gravity},speed:3*e.startVelocity,decay:1-e.decay,direction:-e.angle}}})}const o={fullScreen:{enable:!t.canvas,zIndex:e.zIndex},fpsLimit:120,particles:{number:{value:0},color:{value:e.colors},shape:{type:e.shapes,options:e.shapeOptions},opacity:{value:{min:0,max:1},animation:{enable:!0,sync:!0,speed:s,startValue:"max",destroy:"min"}},size:{value:5*e.scalar},links:{enable:!1},life:{count:1},move:{angle:{value:e.spread,offset:0},drift:{min:-e.drift,max:e.drift},enable:!0,gravity:{enable:!0,acceleration:9.81*e.gravity},speed:3*e.startVelocity,decay:1-e.decay,direction:-e.angle,random:!0,straight:!1,outModes:{default:"none",bottom:"destroy"}},rotate:{value:e.flat?0:{min:0,max:360},direction:"random",animation:{enable:!e.flat,speed:60}},tilt:{direction:"random",enable:!e.flat,value:e.flat?0:{min:0,max:360},animation:{enable:!0,speed:60}},roll:{darken:{enable:!0,value:25},enable:!e.flat,speed:{min:15,max:25}},wobble:{distance:30,enable:!e.flat,speed:{min:-15,max:15}}},detectRetina:!0,motion:{disable:e.disableForReducedMotion},emitters:{name:"confetti",startCount:e.count,position:e.position,size:{width:0,height:0},rate:{delay:0,quantity:0},life:{duration:.1,count:1}}};return i=await Ti.load({id:t.id,element:t.canvas,options:o}),oo.set(t.id,i),i}async function ro(t,e){let i,s;return await no(Ti),wt(t)?(s=t,i=e??{}):(s="confetti",i=t),ao({id:s,options:i})}return ro.create=async(t,e)=>{if(!t)return ro;await no(Ti);const i=t.getAttribute("id")||"confetti";return t.setAttribute("id",i),async(s,o)=>{let n,a;return wt(s)?(a=s,n=o??e):(a=i,n=s),ao({id:a,canvas:t,options:n})}},ro.version=Ti.version,j()||(window.confetti=ro),e})())); \ No newline at end of file diff --git a/assets/js/web-components/prpl-badge-progress-bar.js b/assets/js/web-components/prpl-badge-progress-bar.js deleted file mode 100644 index 0fc6c6f9c5..0000000000 --- a/assets/js/web-components/prpl-badge-progress-bar.js +++ /dev/null @@ -1,216 +0,0 @@ -/* global customElements, HTMLElement */ -/* - * Badge Progress Bar - * - * A web component to display a badge progress bar. - * - * Dependencies: progress-planner/l10n, progress-planner/web-components/prpl-badge - */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-badge-progress-bar', - class extends HTMLElement { - /** - * Observed attributes, defined the attributes that will trigger the attributeChangedCallback. - */ - static get observedAttributes() { - return [ - 'data-badge-id', - 'data-points', - 'data-max-points', - 'data-branding-id', - ]; - } - - /** - * Constructor, ran when the element is instantiated. - */ - constructor() { - super(); - this.attachShadow( { mode: 'open' } ); - this.state = { - badgeId: this.getAttribute( 'data-badge-id' ) || '', - points: parseInt( this.getAttribute( 'data-points' ) || 0 ), - maxPoints: parseInt( - this.getAttribute( 'data-max-points' ) || 10 - ), - brandingId: parseInt( - this.getAttribute( 'data-branding-id' ) || 0 - ), - }; - } - - /** - * Get the points. - */ - get points() { - return parseInt( this.state.points ); - } - - /** - * Set the points. - */ - set points( v ) { - v = Math.max( 0, Math.min( v, this.maxPoints ) ); - this.state.points = v; - this.setAttribute( 'data-points', v ); - } - - /** - * Get the max points. - */ - get maxPoints() { - return parseInt( this.state.maxPoints ); - } - - /** - * Set the max points. - */ - set maxPoints( v ) { - this.state.maxPoints = v; - this.setAttribute( 'data-max-points', v ); - } - - /** - * Get the progress percent. - */ - get progressPercent() { - // Prevent division by zero. - if ( 0 === this.maxPoints ) { - return 0; - } - return ( this.points / this.maxPoints ) * 100; - } - - /** - * Connected callback, ran after the element is connected to the DOM. - */ - connectedCallback() { - this.render(); - } - - /** - * Attribute changed callback, ran on page load and when an observed attribute is changed. - * - * @param {string} name The name of the attribute that was changed. - * @param {string} oldVal The old value of the attribute. - * @param {string} newVal The new value of the attribute. - */ - attributeChangedCallback( name, oldVal, newVal ) { - if ( oldVal === newVal ) { - return; - } - - // Convert attribute name to camelCase, remove "data-" or "aria-" prefix if present. - const camelCaseName = name - .replace( /^(data|aria)-/, '' ) - // Convert kebab-case to camelCase - .replace( /-([a-z])/g, ( _, chr ) => chr.toUpperCase() ); - - // Update state with proper type conversion. - this.state[ camelCaseName ] = [ - 'points', - 'maxPoints', - 'brandingId', - ].includes( camelCaseName ) - ? parseInt( newVal || 0 ) - : newVal; - - // Update progress. - this.updateProgress(); - - // Dispatch event. - this.dispatchEvent( - new CustomEvent( 'prlp-badge-progress-bar-update', { - detail: { - points: this.state.points, - maxPoints: this.state.maxPoints, - badgeId: this.state.badgeId, - element: this, - }, - bubbles: true, - composed: true, - } ) - ); - } - - /** - * Render the gauge. - */ - render() { - this.shadowRoot.innerHTML = ` - -

-
-
- - -
-
- `; - - this.progressEl = this.shadowRoot.querySelector( '.progress' ); - this.badgeEl = this.shadowRoot.querySelector( 'prpl-badge' ); - - this.updateProgress(); - } - - /** - * Update the progress. - */ - updateProgress() { - if ( ! this.progressEl || ! this.badgeEl ) { - return; - } - - this.progressEl.style.width = `${ this.progressPercent }%`; - this.badgeEl.style.left = `calc(${ this.progressPercent }% - 3.75rem)`; - } - } -); diff --git a/assets/js/web-components/prpl-badge.js b/assets/js/web-components/prpl-badge.js deleted file mode 100644 index 5fd3b58a8b..0000000000 --- a/assets/js/web-components/prpl-badge.js +++ /dev/null @@ -1,43 +0,0 @@ -/* global customElements, HTMLElement, progressPlannerBadge, prplL10n */ -/* - * Badge - * - * A web component to display a badge. - * - * Dependencies: progress-planner/l10n - */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-badge', - class extends HTMLElement { - constructor( badgeId, badgeName, brandingId = 0 ) { - // Get parent class properties - super(); - - badgeId = badgeId || this.getAttribute( 'badge-id' ); - badgeName = badgeName || this.getAttribute( 'badge-name' ); - brandingId = brandingId || this.getAttribute( 'branding-id' ); - - let url = `${ progressPlannerBadge.remoteServerRootUrl }/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=${ badgeId }`; - if ( brandingId ) { - url += `&branding_id=${ brandingId }`; - } - - if ( ! badgeName || 'null' === badgeName ) { - badgeName = `${ prplL10n( 'badge' ) }`; - } - - this.innerHTML = ` - ${ badgeName } - `; - } - } -); diff --git a/assets/js/web-components/prpl-big-counter.js b/assets/js/web-components/prpl-big-counter.js deleted file mode 100644 index 6bbbbe341a..0000000000 --- a/assets/js/web-components/prpl-big-counter.js +++ /dev/null @@ -1,74 +0,0 @@ -/* global customElements, HTMLElement */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-big-counter', - class extends HTMLElement { - constructor( number, content, backgroundColor ) { - // Get parent class properties - super(); - number = number || this.getAttribute( 'number' ); - content = content || this.getAttribute( 'content' ); - backgroundColor = - backgroundColor || this.getAttribute( 'background-color' ); - backgroundColor = - backgroundColor || 'var(--prpl-background-content)'; - - const el = this; - - this.innerHTML = ` -
-
- ${ number } - - ${ content } - -
- `; - - const resizeFont = () => { - const element = el.querySelector( '.resize' ); - if ( ! element ) { - return; - } - - element.style.fontSize = '100%'; - - let size = 100; - while ( - element.clientWidth > - el.querySelector( '.container-width' ).clientWidth - ) { - if ( size < 80 ) { - element.style.fontSize = size + '%'; - element.style.width = '100%'; - break; - } - size -= 1; - element.style.fontSize = size + '%'; - } - }; - - resizeFont(); - window.addEventListener( 'resize', resizeFont ); - } - } -); diff --git a/assets/js/web-components/prpl-chart-bar.js b/assets/js/web-components/prpl-chart-bar.js deleted file mode 100644 index 10eecd436e..0000000000 --- a/assets/js/web-components/prpl-chart-bar.js +++ /dev/null @@ -1,72 +0,0 @@ -/* global customElements, HTMLElement */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-chart-bar', - class extends HTMLElement { - constructor( data = [] ) { - // Get parent class properties - super(); - - if ( data.length === 0 ) { - data = JSON.parse( this.getAttribute( 'data' ) ); - } - - const labelsDivider = - data.length > 6 ? parseInt( data.length / 6 ) : 1; - - let html = `
`; - let i = 0; - data.forEach( ( item ) => { - html += `
`; - html += `
`; - // Only display up to 6 labels. - html += ``; - html += - i % labelsDivider === 0 - ? `${ item.label }` - : ``; - html += ``; - html += `
`; - i++; - } ); - html += `
`; - - this.innerHTML = html; - - // Tweak labels styling to fix positioning when there are many items. - if ( this.querySelectorAll( '.label.invisible' ).length > 0 ) { - this.querySelectorAll( '.label-container' ).forEach( - ( label ) => { - const labelWidth = - label.querySelector( '.label' ).offsetWidth; - const labelElement = label.querySelector( '.label' ); - labelElement.style.display = 'block'; - labelElement.style.width = 0; - const marginLeft = - ( label.offsetWidth - labelWidth ) / 2; - if ( labelElement.classList.contains( 'visible' ) ) { - labelElement.style.marginLeft = `${ marginLeft }px`; - } - } - ); - // Reduce the gap between items to avoid overflows. - this.querySelector( '.chart-bar' ).style.gap = - parseInt( - Math.max( - this.querySelector( '.label' ).offsetWidth / 4, - 1 - ) - ) + 'px'; - } - } - } -); diff --git a/assets/js/web-components/prpl-chart-line.js b/assets/js/web-components/prpl-chart-line.js deleted file mode 100644 index 5319da0c29..0000000000 --- a/assets/js/web-components/prpl-chart-line.js +++ /dev/null @@ -1,439 +0,0 @@ -/* global customElements, HTMLElement */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-chart-line', - class extends HTMLElement { - constructor( data = [], options = {} ) { - // Get parent class properties - super(); - - // Set the object data. - this.data = - 0 === data.length - ? JSON.parse( this.getAttribute( 'data' ) ) - : data; - - // Set the object options. - this.options = - 0 === Object.keys( options ).length - ? JSON.parse( this.getAttribute( 'data-options' ) ) - : options; - - // Add default values to the options object. - this.options = { - aspectRatio: 2, - height: 300, - axisOffset: 16, - strokeWidth: 4, - dataArgs: {}, - showCharts: Object.keys( this.options.dataArgs ), - axisColor: 'var(--prpl-color-border)', - rulersColor: 'var(--prpl-color-border)', - filtersLabel: '', - ...this.options, - }; - - // Add the HTML to the element. - this.innerHTML = `${ this.getCheckboxesHTML() }
${ this.getSvgHTML() }
`; - - // Add event listeners for the checkboxes. - this.addCheckboxesEventListeners(); - } - - /** - * Get the checkboxes. - * - * @return {string} The checkboxes. - */ - getCheckboxesHTML = () => - 1 >= Object.keys( this.options.dataArgs ).length - ? '' - : `
${ this.getCheckboxesFiltersLabel() }${ Object.keys( this.options.dataArgs ) - .map( ( key ) => this.getCheckboxHTML( key ) ) - .join( '' ) }
`; - - /** - * Get the HTML for a single checkbox. - * - * @param {string} key - The key of the data. - * - * @return {string} The checkbox HTML. - */ - getCheckboxHTML = ( key ) => - ``; - - /** - * Get the filters label. - * - * @return {string} The filters label. - */ - getCheckboxesFiltersLabel = () => - '' === this.options.filtersLabel - ? '' - : `${ this.options.filtersLabel }`; - - /** - * Generate the SVG for the chart. - * - * @return {string} The SVG HTML for the chart. - */ - getSvgHTML = () => - ` - ${ this.getXAxisLineHTML() } - ${ this.getYAxisLineHTML() } - ${ this.getXAxisLabelsAndRulersHTML() } - ${ this.getYAxisLabelsAndRulersHTML() } - ${ this.getPolyLinesHTML() } - `; - - /** - * Get the poly lines for the SVG. - * - * @return {string} The poly lines. - */ - getPolyLinesHTML = () => - Object.keys( this.data ) - .map( ( key ) => this.getPolylineHTML( key ) ) - .join( '' ); - - /** - * Get a single polyline. - * - * @param {string} key - The key of the data. - * - * @return {string} The polyline. - */ - getPolylineHTML = ( key ) => { - if ( ! this.options.showCharts.includes( key ) ) { - return ''; - } - - const polylinePoints = []; - let xCoordinate = this.options.axisOffset * 3; - this.data[ key ].forEach( ( item ) => { - polylinePoints.push( [ - xCoordinate, - this.calcYCoordinate( item.score ), - ] ); - xCoordinate += this.getXDistanceBetweenPoints(); - } ); - - return ``; - }; - - /** - * Get the number of steps for the Y axis. - * - * Choose between 3, 4, or 5 steps. - * The result should be the number that when used as a divisor, - * produces integer values for the Y labels - or at least as close as possible. - * - * @return {number} The number of steps. - */ - getYLabelsStepsDivider = () => { - const maxValuePadded = this.getMaxValuePadded(); - - const stepsRemainders = { - 4: maxValuePadded % 4, - 5: maxValuePadded % 5, - 3: maxValuePadded % 3, - }; - // Get the smallest remainder. - const smallestRemainder = Math.min( - ...Object.values( stepsRemainders ) - ); - - // Get the key of the smallest remainder. - const smallestRemainderKey = Object.keys( stepsRemainders ).find( - ( key ) => stepsRemainders[ key ] === smallestRemainder - ); - return smallestRemainderKey; - }; - - /** - * Get the Y labels. - * - * @return {number[]} The Y labels. - */ - getYLabels = () => { - const maxValuePadded = this.getMaxValuePadded(); - const yLabelsStepsDivider = this.getYLabelsStepsDivider(); - const yLabelsStep = maxValuePadded / yLabelsStepsDivider; - const yLabels = []; - if ( 100 === maxValuePadded || 15 > maxValuePadded ) { - for ( let i = 0; i <= yLabelsStepsDivider; i++ ) { - yLabels.push( parseInt( yLabelsStep * i ) ); - } - } else { - // Round the values to the nearest 10. - for ( let i = 0; i <= yLabelsStepsDivider; i++ ) { - yLabels.push( - Math.min( - maxValuePadded, - Math.round( yLabelsStep * i, -1 ) - ) - ); - } - } - - return yLabels; - }; - - /** - * Get the X axis line. - * - * @return {string} The X axis line. - */ - getXAxisLineHTML = () => - ``; - - /** - * Get the Y axis line. - * - * @return {string} The Y axis line. - */ - getYAxisLineHTML = () => - ``; - - /** - * Get the X axis labels and rulers. - * - * @return {string} The X axis labels and rulers. - */ - getXAxisLabelsAndRulersHTML = () => { - let html = ''; - let labelXCoordinate = 0; - const dataLength = - this.data[ Object.keys( this.data )[ 0 ] ].length; - const labelsXDivider = Math.round( dataLength / 6 ); - let i = 0; - Object.keys( this.data ).forEach( ( key ) => { - this.data[ key ].forEach( ( item ) => { - labelXCoordinate = - this.getXDistanceBetweenPoints() * i + - this.options.axisOffset * 2; - ++i; - - // Only allow up to 6 labels to prevent overlapping. - // If there are more than 6 labels, find the alternate labels. - if ( - 6 < dataLength && - 1 !== i && - ( i - 1 ) % labelsXDivider !== 0 - ) { - return; - } - - html += `${ item.label }`; - - // Draw the ruler. - if ( 1 !== i ) { - html += ``; - } - } ); - } ); - - return html; - }; - - /** - * Get the distance between the points in the X axis. - * - * @return {number} The distance between the points in the X axis. - */ - getXDistanceBetweenPoints = () => - Math.round( - ( this.options.height * this.options.aspectRatio - - 3 * this.options.axisOffset ) / - ( this.data[ Object.keys( this.data )[ 0 ] ].length - 1 ) - ); - - /** - * Get the Y axis labels and rulers. - * - * @return {string} The Y axis labels and rulers. - */ - getYAxisLabelsAndRulersHTML = () => { - // Y-axis labels and rulers. - let yLabelCoordinate = 0; - let iYLabel = 0; - let html = ''; - this.getYLabels().forEach( ( yLabel ) => { - yLabelCoordinate = this.calcYCoordinate( yLabel ); - - html += `${ yLabel }`; - - // Draw the ruler. - if ( 0 !== iYLabel ) { - html += ``; - } - - ++iYLabel; - } ); - - return html; - }; - - /** - * Get the max value from the data. - * - * @return {number} The max value. - */ - getMaxValue = () => - Object.keys( this.data ).reduce( ( max, key ) => { - if ( this.options.showCharts.includes( key ) ) { - return Math.max( - max, - this.data[ key ].reduce( - ( _max, item ) => Math.max( _max, item.score ), - 0 - ) - ); - } - return max; - }, 0 ); - - /** - * Get the max value padded. - * - * @return {number} The max value padded. - */ - getMaxValuePadded = () => { - const max = this.getMaxValue(); - const maxValue = 100 > max && 70 < max ? 100 : max; - return Math.max( - 100 === maxValue ? 100 : parseInt( maxValue * 1.1 ), - 1 - ); - }; - - /** - * Add event listeners to the checkboxes. - */ - addCheckboxesEventListeners = () => - // Add event listeners to the checkboxes. - this.querySelectorAll( 'input[type="checkbox"]' ).forEach( - ( checkbox ) => { - checkbox.addEventListener( 'change', ( e ) => { - const el = e.target; - const parentEl = el.parentElement; - const checkboxColorEl = parentEl.querySelector( - '.prpl-chart-line-checkbox-color' - ); - if ( el.checked ) { - this.options.showCharts.push( - el.getAttribute( 'name' ) - ); - checkboxColorEl.style.backgroundColor = - parentEl.dataset.color; - } else { - this.options.showCharts = - this.options.showCharts.filter( - ( chart ) => - chart !== el.getAttribute( 'name' ) - ); - checkboxColorEl.style.backgroundColor = - 'transparent'; - } - - // Update the chart. - this.querySelector( '.svg-container' ).innerHTML = - this.getSvgHTML(); - } ); - } - ); - - /** - * Calculate the Y coordinate for a given value. - * - * @param {number} value - The value. - * - * @return {number} The Y coordinate. - */ - calcYCoordinate = ( value ) => { - const maxValuePadded = this.getMaxValuePadded(); - const multiplier = - ( this.options.height - this.options.axisOffset * 2 ) / - this.options.height; - const yCoordinate = - ( maxValuePadded - value * multiplier ) * - ( this.options.height / maxValuePadded ) - - this.options.axisOffset; - return yCoordinate - this.options.strokeWidth / 2; - }; - } -); diff --git a/assets/js/web-components/prpl-gauge-progress-controller.js b/assets/js/web-components/prpl-gauge-progress-controller.js deleted file mode 100644 index 9c5a594497..0000000000 --- a/assets/js/web-components/prpl-gauge-progress-controller.js +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Web Component: prpl-gauge-progress-controller - * - * A web component that controls the progress of a gauge and its progress bars. - * - * Dependencies: progress-planner/web-components/prpl-gauge, progress-planner/web-components/prpl-badge-progress-bar - */ - -// eslint-disable-next-line no-unused-vars -class PrplGaugeProgressController { - constructor( gauge, ...progressBars ) { - this.gauge = gauge; - this.progressBars = progressBars; // array, can be empty. - - this.addListeners(); - } - - /** - * Add listeners to the gauge and progress bars. - */ - addListeners() { - // Monthy badge gauge updated. - // Update the gauge and bars side elements (elements there are not part of the component), for example: the points counter. - document.addEventListener( 'prpl-gauge-update', ( event ) => { - if ( - 'prpl-gauge-ravi' !== event.detail.element.getAttribute( 'id' ) - ) { - return; - } - - // Update the monthly badge gauge points counter. - this.updateGaugePointsCounter( event.detail.value ); - - // Mark badge as (not)completed, in the a Monthly badges widgets (both on page and in the popover), if we reached the max points. - this.maybeUpdateBadgeCompletedStatus( - event.detail.badgeId, - event.detail.value, - event.detail.max - ); - - // Update remaining points side elements for all progress bars, for example: "20 more points to go" text. - this.updateBarsRemainingPoints(); - } ); - - // Progress bar for the previous month badge updated. - // Updates the gauge and bars side elements (elements there are not part of the component), for example: "20 more points to go" text. - document.addEventListener( - 'prlp-badge-progress-bar-update', - ( event ) => { - // Update the remaining points. - const remainingPointsEl = event.detail.element; - - const remainingPointsElWrapper = remainingPointsEl.closest( - '.prpl-previous-month-badge-progress-bar-wrapper' - ); - - if ( remainingPointsElWrapper ) { - // Update the progress bars points number. - const badgePointsNumberEl = - remainingPointsElWrapper.querySelector( - '.prpl-widget-previous-ravi-points-number' - ); - - if ( badgePointsNumberEl ) { - badgePointsNumberEl.textContent = - event.detail.points + 'pt'; - } - - // Mark badge as (not)completed, in the a Monthly badges widgets (both on page and in the popover), if we reached the max points. - this.maybeUpdateBadgeCompletedStatus( - event.detail.badgeId, - event.detail.points, - event.detail.maxPoints - ); - - // Update remaining points text for all progress bars, for example: "20 more points to go". - this.updateBarsRemainingPoints(); - - // Maybe remove the completed progress bar. - this.maybeRemoveCompletedBarFromDom( - event.detail.badgeId, - event.detail.points, - event.detail.maxPoints - ); - } - } - ); - } - - /** - * Update the monthly badge gauge points counter. - * - * @param {number} value The value. - */ - updateGaugePointsCounter( value ) { - // Update the points counter. - const pointsCounter = document.getElementById( - 'prpl-widget-content-ravi-points-number' - ); - - if ( pointsCounter ) { - pointsCounter.textContent = parseInt( value ) + 'pt'; - } - } - - /** - * Update the remaining points display for all progress bars based on current gauge and progress bar values. - * For example: "11 more points to go" text. - */ - updateBarsRemainingPoints() { - const currentGaugeValue = this.gaugeValue; - - for ( let i = 0; i < this.progressBars.length; i++ ) { - const bar = this.progressBars[ i ]; - - // Calculate remaining points for this bar - let remainingPoints = 0; - if ( currentGaugeValue < this.gaugeMax ) { - // Calculate the threshold for this progress bar - // First bar starts at gauge max (10), second at gauge max + first bar max (20), etc. - const barThreshold = - this.gaugeMax + ( i + 1 ) * this._barMaxPoints( bar ); - - // Gauge is not full yet, show points needed to reach this bar - remainingPoints = barThreshold - currentGaugeValue; - } else { - // Gauge is full, show remaining points in this specific bar - for ( let j = 0; j <= i; j++ ) { - remainingPoints += - this._barMaxPoints( this.progressBars[ j ] ) - - this._barValue( this.progressBars[ j ] ); - } - } - - // Ensure remaining points is never negative - remainingPoints = Math.max( 0, remainingPoints ); - - // Update the display - const parentWrapper = bar.closest( - '.prpl-previous-month-badge-progress-bar-wrapper' - ); - - if ( parentWrapper ) { - const numberEl = parentWrapper.querySelector( '.number' ); - if ( numberEl ) { - numberEl.textContent = remainingPoints; - } - } - } - } - - /** - * Maybe update the badge completed status. - * This sets the complete attribute on the badge element and toggles visibility of the ! icon. - * - * @param {string} badgeId The badge id. - * @param {number} value The value. - * @param {number} max The max. - */ - maybeUpdateBadgeCompletedStatus( badgeId, value, max ) { - if ( ! badgeId ) { - return; - } - - // See if the badge is completed or not, this is used as attribute value. - const badgeCompleted = - parseInt( value ) >= parseInt( max ) ? 'true' : 'false'; - - // If the badge was completed we need to select all badges with the same badge-id which are marked as not completed. - // And vice versa. - const badgeSelector = `prpl-badge[complete="${ - 'true' === badgeCompleted ? 'false' : 'true' - }"][badge-id="${ badgeId }"]`; - - // We have multiple badges, one in widget and the other in the popover. - document - .querySelectorAll( - `.prpl-badge-row-wrapper .prpl-badge ${ badgeSelector }` - ) - ?.forEach( ( badge ) => { - badge.setAttribute( 'complete', badgeCompleted ); - } ); - } - - /** - * Maybe remove the completed bar. - * - * @param {string} badgeId The badge id. - * @param {number} value The value. - * @param {number} max The max. - */ - maybeRemoveCompletedBarFromDom( badgeId, value, max ) { - if ( ! badgeId ) { - return; - } - - // If the previous month badge is completed, remove the progress bar. - if ( value >= parseInt( max ) ) { - // Remove the previous month badge progress bar. - document - .querySelector( - `.prpl-previous-month-badge-progress-bar-wrapper[data-badge-id="${ badgeId }"]` - ) - ?.remove(); - - // If there are no more progress bars, remove the previous month badge progress bar wrapper. - if ( - ! document.querySelector( - '.prpl-previous-month-badge-progress-bar-wrapper' - ) - ) { - document - .querySelector( - '.prpl-previous-month-badge-progress-bars-wrapper' - ) - ?.remove(); - } - } - } - - /** - * Get the gauge value. - */ - get gaugeValue() { - return parseInt( this.gauge.value ) || 0; - } - - /** - * Set the gauge value. - * - * @param {number} v The value. - */ - set gaugeValue( v ) { - this.gauge.value = v; - } - - /** - * Get the gauge max. - */ - get gaugeMax() { - return parseInt( this.gauge.max ) || 10; - } - - /** - * Get the bar value. - * - * @param {number} bar The bar. - * @return {number} The value. - */ - _barValue( bar ) { - return parseInt( bar.points ) || 0; - } - - /** - * Set the bar value. - * - * @param {number} bar The bar. - * @param {number} v The value. - */ - _setBarValue( bar, v ) { - bar.points = v; - } - - /** - * Get the bar max points. - * - * @param {number} bar The bar. - * @return {number} The max points. - */ - _barMaxPoints( bar ) { - return parseInt( bar.maxPoints ) || 10; - } - - /** - * Increase the gauge and progress bars. - * This method is used to sync the gauge and progress bars. - * - * @param {number} amount The amount. - */ - increase( amount = 1 ) { - let remaining = amount; - - // Fill gauge first - const gaugeSpace = this.gaugeMax - this.gaugeValue; - const toGauge = Math.min( remaining, gaugeSpace ); - this.gaugeValue += toGauge; - remaining -= toGauge; - - // Fill progress bars in order - for ( const bar of this.progressBars ) { - if ( remaining <= 0 ) { - break; - } - const barSpace = parseInt( bar.maxPoints ) - this._barValue( bar ); - - const toBar = Math.min( remaining, barSpace ); - - this._setBarValue( bar, this._barValue( bar ) + toBar ); - remaining -= toBar; - } - } - - /** - * Decrease the gauge and progress bars. - * This method is used to sync the gauge and progress bars. - * - * @param {number} amount The amount. - */ - decrease( amount = 1 ) { - // Convert negative amount to positive. - if ( 0 > amount ) { - amount = -amount; - } - - let remaining = amount; - - // Decrease progress bars first, in reverse order - for ( let i = this.progressBars.length - 1; i >= 0; i-- ) { - if ( remaining <= 0 ) { - break; - } - const bar = this.progressBars[ i ]; - const barVal = this._barValue( bar ); - const fromBar = Math.min( remaining, barVal ); - this._setBarValue( bar, barVal - fromBar ); - remaining -= fromBar; - } - - // Decrease gauge last - if ( remaining > 0 ) { - this.gaugeValue -= remaining; - } - } -} diff --git a/assets/js/web-components/prpl-gauge.js b/assets/js/web-components/prpl-gauge.js deleted file mode 100644 index a2786104aa..0000000000 --- a/assets/js/web-components/prpl-gauge.js +++ /dev/null @@ -1,271 +0,0 @@ -/* global customElements, HTMLElement, PrplGaugeProgressController */ -/* - * Web Component: prpl-gauge - * - * A web component that displays a gauge. - * - * Dependencies: progress-planner/web-components/prpl-badge, progress-planner/web-components/prpl-badge-progress-bar, progress-planner/web-components/prpl-gauge-progress-controller - */ - -/** - * Register the custom web component. - */ - -customElements.define( - 'prpl-gauge', - class extends HTMLElement { - /** - * Observed attributes, defined the attributes that will trigger the attributeChangedCallback. - */ - static get observedAttributes() { - return [ - 'data-value', - 'data-max', - 'maxdeg', - 'background', - 'color', - 'color2', - 'start', - 'cutout', - 'contentfontsize', - 'contentpadding', - 'marginbottom', - 'branding-id', - 'data-badge-id', - ]; - } - - /** - * Constructor, ran when the element is instantiated. - */ - constructor() { - super(); - this.attachShadow( { mode: 'open' } ); - this.state = { - max: 10, - value: 0, - maxDeg: '180deg', - background: 'var(--prpl-background-monthly)', - color: 'var(--prpl-color-monthly)', - color2: 'var(--prpl-color-monthly-2)', - start: '270deg', - cutout: '57%', - contentFontSize: 'var(--prpl-font-size-6xl)', - contentPadding: - 'var(--prpl-padding) var(--prpl-padding) calc(var(--prpl-padding) * 2) var(--prpl-padding)', - marginBottom: 'var(--prpl-padding)', - brandingId: 0, - content: '', - }; - } - - /** - * Get the value of the gauge. - */ - get value() { - return parseInt( this.state.value ); - } - - /** - * Set the value of the gauge. - */ - set value( v ) { - v = Math.max( 0, Math.min( v, this.max ) ); - this.state.value = v; - this.setAttribute( 'data-value', v ); - } - - /** - * Get the max of the gauge. - */ - get max() { - return parseInt( this.state.max ); - } - - /** - * Set the max of the gauge. - */ - set max( v ) { - this.state.max = v; - this.setAttribute( 'data-max', v ); - } - - /** - * Connected callback, ran after the element is connected to the DOM. - */ - connectedCallback() { - // Wait for slot to be populated, wait for the next 'tick' - this will be executed last. - setTimeout( () => { - const slot = this.shadowRoot.querySelector( 'slot' ); - const nodes = slot.assignedElements(); - - if ( 0 < nodes.length ) { - const hasPrplBadge = nodes.some( - ( node ) => - node.tagName.toLowerCase() === 'prpl-badge' || - node.innerHTML.includes( ' - chr.toUpperCase() - ); - - // Update state. - this.state[ camelCaseName ] = newVal; - break; - } - - // Render the gauge. - this.render(); - - // Dispatch event. - this.dispatchEvent( - new CustomEvent( 'prpl-gauge-update', { - detail: { - value: this.state.value, - max: this.state.max, - element: this, - badgeId: this.state.badgeId, - }, - bubbles: true, - composed: true, - } ) - ); - } - - /** - * Render the gauge. - */ - render() { - const { - max, - value, - maxDeg, - background, - color, - color2, - start, - cutout, - contentFontSize, - contentPadding, - marginBottom, - content, - } = this.state; - - const contentSpecificStyles = content.includes( ' -
- 0 - - - - - - ${ max } -
- - `; - } - } -); - -/** - * Update the Ravi gauge. - * - * @param {number} pointsDiff The points difference. - * - * @return {void} - */ -// eslint-disable-next-line no-unused-vars -const prplUpdateRaviGauge = ( pointsDiff ) => { - if ( ! pointsDiff ) { - return; - } - - // Get the gauge. - const controllerGauge = document.getElementById( 'prpl-gauge-ravi' ); - - if ( ! controllerGauge ) { - return; - } - - // Get the progress bars, if any. - const progressBarElements = document.querySelectorAll( - '.prpl-previous-month-badge-progress-bars-wrapper prpl-badge-progress-bar' - ); - - const controlProgressBars = progressBarElements.length - ? [ ...progressBarElements ] - : []; - - // Create the controller. - const controller = new PrplGaugeProgressController( - controllerGauge, - ...controlProgressBars - ); - - // Handle points difference. - if ( 0 < pointsDiff ) { - controller.increase( pointsDiff ); - } else { - controller.decrease( pointsDiff ); - } -}; diff --git a/assets/js/web-components/prpl-install-plugin.js b/assets/js/web-components/prpl-install-plugin.js deleted file mode 100644 index 7c29942314..0000000000 --- a/assets/js/web-components/prpl-install-plugin.js +++ /dev/null @@ -1,155 +0,0 @@ -/* global customElements, HTMLElement, prplL10n, progressPlanner, progressPlannerAjaxRequest, prplSuggestedTask */ -/* - * Install Plugin - * - * A web component to install a plugin. - * - * Dependencies: progress-planner/l10n, progress-planner/ajax-request, progress-planner/suggested-task - */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-install-plugin', - class extends HTMLElement { - constructor( - pluginSlug, - pluginName, - action, - providerId, - className = 'prpl-button-link', - completeTaskAttr = 'true' // String on purpose, since element attributes are always strings. - ) { - // Get parent class properties - super(); - - this.pluginSlug = - pluginSlug ?? this.getAttribute( 'data-plugin-slug' ); - this.pluginName = - pluginName ?? this.getAttribute( 'data-plugin-name' ); - this.pluginName = this.pluginName ?? this.pluginSlug; - this.action = action ?? this.getAttribute( 'data-action' ); - this.completeTaskAttr = - completeTaskAttr ?? this.getAttribute( 'data-complete-task' ); - this.providerId = - providerId ?? this.getAttribute( 'data-provider-id' ); - this.className = className ?? this.getAttribute( 'class' ); - // If the plugin slug is empty, bail out. - if ( ! this.pluginSlug ) { - return; - } - - // Convert the string to a boolean. - this.completeTaskAttr = 'true' === this.completeTaskAttr; - - // Set the inner HTML. - this.innerHTML = ` - - `; - - // Handle the click event. - this.handleClick(); - } - - /** - * Handle the click event. - */ - handleClick() { - const button = this.querySelector( 'button' ); - if ( ! button ) { - return; - } - - button.addEventListener( 'click', () => { - button.disabled = true; - if ( 'install' === this.action ) { - this.installPlugin(); - } else { - this.activatePlugin(); - } - } ); - } - - installPlugin() { - const button = this.querySelector( 'button' ); - const thisObj = this; - - button.innerHTML = ` - - ${ prplL10n( 'installing' ) } - `; - - progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action: 'progress_planner_install_plugin', - plugin_slug: this.pluginSlug, - plugin_name: this.pluginName, - nonce: progressPlanner.nonce, - }, - } ) - .then( () => thisObj.activatePlugin() ) - .catch( ( error ) => console.error( error ) ); - } - - activatePlugin() { - const button = this.querySelector( 'button' ); - const thisObj = this; - button.innerHTML = ` - - ${ prplL10n( 'activating' ) } - `; - - progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action: 'progress_planner_activate_plugin', - plugin_slug: thisObj.pluginSlug, - plugin_name: thisObj.pluginName, - nonce: progressPlanner.nonce, - }, - } ) - .then( () => { - button.innerHTML = prplL10n( 'activated' ); - - // Complete the task if the completeTask attribute is set to true. - if ( true === thisObj.completeTaskAttr ) { - thisObj.completeTask(); - } - } ) - .catch( ( error ) => console.error( error ) ); - } - - /** - * Complete the task. - */ - completeTask() { - const tasks = document.querySelectorAll( - '#prpl-suggested-tasks-list .prpl-suggested-task' - ); - const thisObj = this; - - tasks.forEach( ( taskElement ) => { - if ( taskElement.dataset.taskId === thisObj.providerId ) { - // Close popover. - document - .getElementById( 'prpl-popover-' + thisObj.providerId ) - .hidePopover(); - - const postId = parseInt( taskElement.dataset.postId ); - - if ( postId ) { - prplSuggestedTask.maybeComplete( postId ); - } - } - } ); - } - } -); diff --git a/assets/js/web-components/prpl-interactive-task.js b/assets/js/web-components/prpl-interactive-task.js deleted file mode 100644 index 07d758a589..0000000000 --- a/assets/js/web-components/prpl-interactive-task.js +++ /dev/null @@ -1,112 +0,0 @@ -/* global HTMLElement, prplSuggestedTask, customElements */ - -/** - * Register the custom web component. - */ -// eslint-disable-next-line no-unused-vars -class PrplInteractiveTask extends HTMLElement { - /** - * Runs when the component is added to the DOM. - */ - connectedCallback() { - const popoverId = this.getAttribute( 'popover-id' ); - - // Add default event listeners. - this.attachDefaultEventListeners(); - - // Allow child components to add event listeners when the popover is added to the DOM. - this.popoverAddedToDOM(); - - // Add popover close event listener. - const popover = document.getElementById( popoverId ); - popover.addEventListener( 'beforetoggle', ( event ) => { - if ( event.newState === 'open' ) { - this.popoverOpening(); - } - - if ( event.newState === 'closed' ) { - this.popoverClosing(); - } - } ); - } - - /** - * Attach button event listeners. - * Every button with a data-action attribute will be handled by the component. - */ - attachDefaultEventListeners() { - // Add event listeners. - this.querySelectorAll( 'button' ).forEach( ( buttonElement ) => { - buttonElement.addEventListener( 'click', ( e ) => { - const button = e.target.closest( 'button' ); - const action = button?.dataset.action; - if ( action && typeof this[ action ] === 'function' ) { - this[ action ](); - } - } ); - } ); - } - - /** - * Runs when the popover is added to the DOM. - */ - popoverAddedToDOM() {} - - /** - * Runs when the popover is opening. - */ - popoverOpening() {} - - /** - * Runs when the popover is closing. - */ - popoverClosing() {} - - /** - * Complete the task. - */ - completeTask() { - const providerId = this.getAttribute( 'provider-id' ); - const tasks = document.querySelectorAll( - '#prpl-suggested-tasks-list .prpl-suggested-task' - ); - - tasks.forEach( ( taskElement ) => { - if ( taskElement.dataset.taskId === providerId ) { - // Close popover. - document - .getElementById( 'prpl-popover-' + providerId ) - .hidePopover(); - - const postId = parseInt( taskElement.dataset.postId ); - - if ( postId ) { - prplSuggestedTask.maybeComplete( postId ); - } - } - } ); - } - - /** - * Close the popover. - */ - closePopover() { - const popoverId = this.getAttribute( 'popover-id' ); - const popover = document.getElementById( popoverId ); - popover.hidePopover(); - } -} - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-interactive-task-popover', - class extends PrplInteractiveTask { - // eslint-disable-next-line no-useless-constructor - constructor() { - // Get parent class properties - super(); - } - } -); diff --git a/assets/js/web-components/prpl-task-improve-pdf-handling.js b/assets/js/web-components/prpl-task-improve-pdf-handling.js deleted file mode 100644 index 6b29305f05..0000000000 --- a/assets/js/web-components/prpl-task-improve-pdf-handling.js +++ /dev/null @@ -1,81 +0,0 @@ -/* global customElements, PrplInteractiveTask */ -/* - * Web Component: prpl-email-test-popup - * - * A web component that displays a gauge. - * - * Dependencies: progress-planner/web-components/prpl-interactive-task, progress-planner/web-components/prpl-install-plugin - */ -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-improve-pdf-handling-popup', - class extends PrplInteractiveTask { - // eslint-disable-next-line no-useless-constructor - constructor() { - // Get parent class properties - super(); - - // First step. - this.firstStep = this.querySelector( - '#prpl-improve-pdf-handling-first-step' - ); - } - - /** - * Runs when the popover is added to the DOM. - */ - popoverAddedToDOM() { - super.popoverAddedToDOM(); - } - - /** - * Hide all steps. - */ - hideAllSteps() { - this.querySelectorAll( '.prpl-task-step' ).forEach( ( step ) => { - step.style.display = 'none'; - } ); - } - - /** - * Show the form (first step). - */ - showFirstStep() { - this.hideAllSteps(); - - this.firstStep.style.display = 'flex'; - } - - /** - * Show the PDF XML Sitemap step. - */ - showPdfXmlSitemapStep() { - this.hideAllSteps(); - - this.querySelector( - '#prpl-improve-pdf-handling-pdf-xml-sitemap-step' - ).style.display = 'flex'; - } - - /** - * Show final success message. - */ - showSuccess() { - this.hideAllSteps(); - - this.querySelector( - '#prpl-improve-pdf-handling-success-step' - ).style.display = 'flex'; - } - - /** - * Popover closing, reset the layout, values, etc. - */ - popoverClosing() { - // Hide all steps and show the first step. - this.showFirstStep(); - } - } -); diff --git a/assets/js/web-components/prpl-task-sending-email.js b/assets/js/web-components/prpl-task-sending-email.js deleted file mode 100644 index 21f9c3e8ae..0000000000 --- a/assets/js/web-components/prpl-task-sending-email.js +++ /dev/null @@ -1,224 +0,0 @@ -/* global customElements, PrplInteractiveTask, prplEmailSending */ -/* - * Web Component: prpl-email-test-popup - * - * A web component that displays a gauge. - * - * Dependencies: progress-planner/web-components/prpl-interactive-task - */ -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-email-test-popup', - class extends PrplInteractiveTask { - // eslint-disable-next-line no-useless-constructor - constructor() { - // Get parent class properties - super(); - - // First step. - this.formStep = this.querySelector( - '#prpl-sending-email-form-step' - ); - } - - /** - * Runs when the popover is added to the DOM. - */ - popoverAddedToDOM() { - super.popoverAddedToDOM(); - - // For the results step, add event listener to radio buttons. - const nextButton = this.querySelector( - '#prpl-sending-email-result-step .prpl-steps-nav-wrapper .prpl-button' - ); - - if ( nextButton ) { - this.querySelectorAll( - 'input[name="prpl-sending-email-result"]' - ).forEach( ( input ) => { - input.addEventListener( 'change', ( event ) => { - nextButton.setAttribute( - 'data-action', - event.target.getAttribute( 'data-action' ) - ); - } ); - } ); - } - } - - /** - * Hide all steps. - */ - hideAllSteps() { - this.querySelectorAll( '.prpl-sending-email-step' ).forEach( - ( step ) => { - step.style.display = 'none'; - } - ); - } - - /** - * Show the form (first step). - */ - showForm() { - this.hideAllSteps(); - - this.formStep.style.display = 'flex'; - } - - /** - * Show the results. - */ - showResults() { - const resultsStep = this.querySelector( - '#prpl-sending-email-result-step' - ); - - const emailAddress = this.querySelector( - '#prpl-sending-email-address' - ); - - // Update result message with the email address. - let resultMessageText = resultsStep - .querySelector( '#prpl-sending-email-sent-message' ) - .getAttribute( 'data-email-message' ); - - // Replace the placeholder with the email address. - resultMessageText = resultMessageText.replace( - '[EMAIL_ADDRESS]', - emailAddress.value - ); - - // Replace the placeholder with the error message. - resultsStep.querySelector( - '#prpl-sending-email-sent-message' - ).textContent = resultMessageText; - - // Make AJAX POST request. - const formData = new FormData(); - formData.append( 'action', 'prpl_test_email_sending' ); - formData.append( 'email_address', emailAddress.value ); - formData.append( '_wpnonce', prplEmailSending.nonce ); - - fetch( prplEmailSending.ajax_url, { - method: 'POST', - body: formData, - } ) - .then( ( response ) => response.json() ) - // eslint-disable-next-line no-unused-vars - .then( ( response ) => { - if ( true === response.success ) { - this.formStep.style.display = 'none'; - resultsStep.style.display = 'flex'; - } else { - this.showErrorOccurred( response.data ); - } - } ) - .catch( ( error ) => { - console.error( 'Error testing email:', error ); // eslint-disable-line no-console - this.showErrorOccurred( error.message ); - } ); - } - - /** - * Show the error occurred. - * @param {string} errorMessageReason - */ - showErrorOccurred( errorMessageReason = '' ) { - if ( ! errorMessageReason ) { - errorMessageReason = prplEmailSending.unknown_error; - } - - const errorOccurredStep = this.querySelector( - '#prpl-sending-email-error-occurred-step' - ); - - // Replace the placeholder with the email address (text in the left column). - const emailAddress = this.querySelector( - '#prpl-sending-email-address' - ).value; - - // Get the error message text. - const errorMessageText = errorOccurredStep - .querySelector( '#prpl-sending-email-error-occurred-message' ) - .getAttribute( 'data-email-message' ); - - // Replace the placeholder with the email address. - errorOccurredStep.querySelector( - '#prpl-sending-email-error-occurred-message' - ).textContent = errorMessageText.replace( - '[EMAIL_ADDRESS]', - emailAddress - ); - - // Replace the placeholder with the error message (text in the right column). - const errorMessageNotification = errorOccurredStep.querySelector( - '.prpl-note.prpl-note-error .prpl-note-text' - ); - const errorMessageNotificationText = - errorMessageNotification.getAttribute( 'data-email-message' ); - - errorMessageNotification.textContent = - errorMessageNotificationText.replace( - '[ERROR_MESSAGE]', - errorMessageReason - ); - - // Hide form step. - this.formStep.style.display = 'none'; - - // Show error occurred step. - errorOccurredStep.style.display = 'flex'; - } - - /** - * Show the troubleshooting. - */ - showSuccess() { - this.hideAllSteps(); - - this.querySelector( - '#prpl-sending-email-success-step' - ).style.display = 'flex'; - } - - /** - * Show the troubleshooting. - */ - showTroubleshooting() { - this.hideAllSteps(); - - this.querySelector( - '#prpl-sending-email-troubleshooting-step' - ).style.display = 'flex'; - } - - /** - * Open the troubleshooting guide. - */ - openTroubleshootingGuide() { - // Open the troubleshooting guide in a new tab. - window.open( prplEmailSending.troubleshooting_guide_url, '_blank' ); - - // Close the popover. - this.closePopover(); - } - - /** - * Popover closing, reset the layout, values, etc. - */ - popoverClosing() { - // Hide all steps and show the first step. - this.showForm(); - - // Reset radio buttons. - this.querySelectorAll( - 'input[name="prpl-sending-email-result"]' - ).forEach( ( input ) => { - input.checked = false; - } ); - } - } -); diff --git a/assets/js/web-components/prpl-tooltip.js b/assets/js/web-components/prpl-tooltip.js deleted file mode 100644 index c0b3e8fddf..0000000000 --- a/assets/js/web-components/prpl-tooltip.js +++ /dev/null @@ -1,138 +0,0 @@ -/* global customElements, HTMLElement, prplL10n */ -/* - * Tooltip - * - * A web component to display a tooltip. - * - * Dependencies: progress-planner/l10n - */ -/* eslint-disable camelcase */ - -/** - * Register the custom web component. - */ -customElements.define( - 'prpl-tooltip', - class extends HTMLElement { - // constructor() { - // // Get parent class properties - // super(); - // } - - /** - * Connected callback. - */ - connectedCallback() { - // Find the elements inside - const contentSlot = this.querySelector( 'slot[name="content"]' ); - const openSlot = this.querySelector( 'slot[name="open"]' ); - const openIconSlot = this.querySelector( 'slot[name="open-icon"]' ); - const closeSlot = this.querySelector( 'slot[name="close"]' ); - const closeIconSlot = this.querySelector( - 'slot[name="close-icon"]' - ); - - // Create tooltip container - const tooltipContent = document.createElement( 'div' ); - tooltipContent.className = 'prpl-tooltip'; - tooltipContent.setAttribute( 'data-tooltip-content', '' ); - tooltipContent.setAttribute( 'role', 'tooltip' ); - tooltipContent.setAttribute( 'aria-hidden', 'true' ); - // Generate a unique ID for the tooltip. - const tooltipId = - 'prpl-tooltip-' + Math.random().toString( 36 ).substr( 2, 9 ); - tooltipContent.setAttribute( 'id', tooltipId ); - - // Move content inside the tooltip container - while ( contentSlot?.childNodes.length ) { - tooltipContent.appendChild( contentSlot.childNodes[ 0 ] ); - } - contentSlot?.remove(); // Remove slot element - - // Find the open button (or create a default one) - let openButton = openSlot?.firstElementChild; - if ( ! openButton ) { - openButton = document.createElement( 'button' ); - openButton.type = 'button'; - openButton.className = 'prpl-info-icon'; - openButton.innerHTML = - openIconSlot?.innerHTML || - ` - - - ${ prplL10n( 'info' ) } - - `; - } - - // Add data attribute to the open button. - openButton.setAttribute( 'data-tooltip-action', 'open-tooltip' ); - // Connect button to tooltip for screen readers. - openButton.setAttribute( 'aria-describedby', tooltipId ); - - openSlot?.remove(); // Remove slot element - openIconSlot?.remove(); // Remove slot element - - // Find the close button (or create a default one) - let closeButton = closeSlot?.firstElementChild; - if ( ! closeButton ) { - closeButton = document.createElement( 'button' ); - closeButton.type = 'button'; - closeButton.className = 'prpl-tooltip-close'; - closeButton.setAttribute( - 'data-tooltip-action', - 'close-tooltip' - ); - closeButton.innerHTML = - closeIconSlot?.innerHTML || - ` - - ${ prplL10n( 'close' ) } - `; - } - closeSlot?.remove(); // Remove slot element - closeIconSlot?.remove(); // Remove slot element - - // Append elements to the component - this.appendChild( openButton ); - tooltipContent.appendChild( closeButton ); - this.appendChild( tooltipContent ); - - // Add event listeners - this.addListeners(); - } - - /** - * Add listeners to the item. - */ - addListeners = () => { - const thisObj = this, - openTooltipButton = thisObj.querySelector( - 'button[data-tooltip-action="open-tooltip"]' - ), - closeTooltipButton = thisObj.querySelector( - 'button[data-tooltip-action="close-tooltip"]' - ); - - // Open the tooltip. - openTooltipButton?.addEventListener( 'click', () => { - const tooltip = thisObj.querySelector( - '[data-tooltip-content]' - ); - tooltip.setAttribute( 'data-tooltip-visible', 'true' ); - tooltip.removeAttribute( 'aria-hidden' ); - } ); - - // Close the tooltip. - closeTooltipButton?.addEventListener( 'click', () => { - const tooltip = thisObj.querySelector( - '[data-tooltip-content]' - ); - tooltip.removeAttribute( 'data-tooltip-visible' ); - tooltip.setAttribute( 'aria-hidden', 'true' ); - } ); - }; - } -); - -/* eslint-enable camelcase */ diff --git a/assets/js/widgets/suggested-tasks.js b/assets/js/widgets/suggested-tasks.js deleted file mode 100644 index 102529bdea..0000000000 --- a/assets/js/widgets/suggested-tasks.js +++ /dev/null @@ -1,258 +0,0 @@ -/* global prplSuggestedTask, prplTerms, prplTodoWidget, prplL10nStrings, history, prplDocumentReady */ -/* - * Widget: Suggested Tasks - * - * A widget that displays a list of suggested tasks. - * - * Dependencies: wp-api, progress-planner/document-ready, progress-planner/suggested-task, progress-planner/widgets/todo, progress-planner/celebrate, progress-planner/web-components/prpl-tooltip, progress-planner/suggested-task-terms - */ -/* eslint-disable camelcase */ - -const prplSuggestedTasksWidget = { - /** - * Remove the "Loading..." text and resize the grid items. - */ - removeLoadingItems: () => { - document.querySelector( '.prpl-suggested-tasks-loading' )?.remove(); - setTimeout( - () => window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ), - 2000 - ); - }, - - /** - * Populate the suggested tasks list. - */ - populateList: () => { - // Do nothing if the list does not exist. - if ( ! document.querySelector( '.prpl-suggested-tasks-list' ) ) { - return; - } - - // If preloaded tasks are available, inject them. - if ( 'undefined' !== typeof prplSuggestedTask.tasks ) { - // Inject the pending tasks. - if ( - Array.isArray( prplSuggestedTask.tasks.pendingTasks ) && - prplSuggestedTask.tasks.pendingTasks.length - ) { - prplSuggestedTask.injectItems( - prplSuggestedTask.tasks.pendingTasks - ); - } - - // Inject the pending celebration tasks, but only on Progress Planner dashboard page. - if ( - ! prplSuggestedTask.delayCelebration && - Array.isArray( - prplSuggestedTask.tasks.pendingCelebrationTasks - ) && - prplSuggestedTask.tasks.pendingCelebrationTasks.length - ) { - prplSuggestedTask.injectItems( - prplSuggestedTask.tasks.pendingCelebrationTasks - ); - - // Set post status to trash. - prplSuggestedTask.tasks.pendingCelebrationTasks.forEach( - ( task ) => { - const post = new wp.api.models.Prpl_recommendations( { - id: task.id, - } ); - // Destroy the post, without the force parameter. - post.destroy( { url: post.url() } ); - } - ); - - // Trigger the celebration event (trigger confetti, strike through tasks, remove from DOM). - setTimeout( () => { - // Trigger the celebration event. - document.dispatchEvent( - new CustomEvent( 'prpl/celebrateTasks' ) - ); - - /** - * Strike completed tasks and remove them from the DOM. - */ - document.dispatchEvent( - new CustomEvent( 'prpl/removeCelebratedTasks' ) - ); - - // Trigger the grid resize event. - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ); - }, 3000 ); - } - - // Toggle the "Loading..." text. - prplSuggestedTasksWidget.removeLoadingItems(); - } else { - // Otherwise, inject tasks from the API. - // Inject published tasks (excluding user tasks). - const tasksPerPage = - 'undefined' !== typeof prplSuggestedTask.tasksPerPage && - -1 === prplSuggestedTask.tasksPerPage - ? 100 - : prplSuggestedTask.tasksPerPage || - prplSuggestedTask.perPageDefault; - - prplSuggestedTask - .fetchItems( { - status: [ 'publish' ], - per_page: tasksPerPage, - exclude_provider: 'user', - } ) - .then( ( data ) => { - if ( data.length ) { - prplSuggestedTask.injectItems( data ); - } - } ); - - // We trigger celebration only on Progress Planner dashboard page. - if ( ! prplSuggestedTask.delayCelebration ) { - // Inject pending celebration tasks. - prplSuggestedTask - .fetchItems( { - status: [ 'pending' ], - per_page: tasksPerPage, - exclude_provider: 'user', - } ) - .then( ( data ) => { - // If there were pending tasks. - if ( data.length ) { - prplSuggestedTask.injectItems( data ); - - // Set post status to trash. - data.forEach( ( task ) => { - const post = - new wp.api.models.Prpl_recommendations( { - id: task.id, - } ); - // Destroy the post, without the force parameter. - post.destroy( { url: post.url() } ); - } ); - - // Trigger the celebration event (trigger confetti, strike through tasks, remove from DOM). - setTimeout( () => { - // Trigger the celebration event. - document.dispatchEvent( - new CustomEvent( 'prpl/celebrateTasks' ) - ); - - /** - * Strike completed tasks and remove them from the DOM. - */ - document.dispatchEvent( - new CustomEvent( - 'prpl/removeCelebratedTasks' - ) - ); - - // Trigger the grid resize event. - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ); - }, 3000 ); - } - } ); - } - } - }, -}; - -/** - * Populate the suggested tasks list when the terms are loaded. - */ -prplTerms.getCollectionsPromises().then( () => { - prplSuggestedTasksWidget.populateList(); - prplTodoWidget.populateList(); -} ); - -/** - * Handle the "Show all recommendations" / "Show fewer recommendations" toggle. - */ -prplDocumentReady( () => { - const toggleButton = document.getElementById( - 'prpl-toggle-all-recommendations' - ); - if ( ! toggleButton ) { - return; - } - - toggleButton.addEventListener( 'click', () => { - const showAll = toggleButton.dataset.showAll === '1'; - const newPerPage = showAll ? prplSuggestedTask.perPageDefault : 100; - - // Update button text and state. - toggleButton.textContent = showAll - ? prplL10nStrings.showAllRecommendations - : prplL10nStrings.showFewerRecommendations; - toggleButton.dataset.showAll = showAll ? '0' : '1'; - toggleButton.disabled = true; - - // Clear existing tasks. - const tasksList = document.getElementById( - 'prpl-suggested-tasks-list' - ); - tasksList.innerHTML = ''; - - // Clear the injected items tracking array so tasks can be fetched again. - prplSuggestedTask.injectedItemIds = []; - - // Show loading message. - const loadingMessage = document.createElement( 'p' ); - loadingMessage.className = 'prpl-suggested-tasks-loading'; - loadingMessage.textContent = prplL10nStrings.loadingTasks; - tasksList.parentNode.insertBefore( - loadingMessage, - tasksList.nextSibling - ); - - // Fetch and inject new tasks. - prplSuggestedTask - .fetchItems( { - status: [ 'publish' ], - per_page: newPerPage, - exclude_provider: 'user', - } ) - .then( ( data ) => { - if ( data.length ) { - prplSuggestedTask.injectItems( data ); - } - - // Remove loading message. - loadingMessage?.remove(); - - // Re-enable button. - toggleButton.disabled = false; - - // Trigger grid resize. - setTimeout( () => { - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ); - }, 100 ); - } ) - .catch( () => { - // On error, restore button state. - toggleButton.textContent = showAll - ? prplL10nStrings.showFewerRecommendations - : prplL10nStrings.showAllRecommendations; - toggleButton.dataset.showAll = showAll ? '1' : '0'; - toggleButton.disabled = false; - loadingMessage?.remove(); - } ); - - // Update URL without reload. - const url = new URL( window.location ); - if ( showAll ) { - url.searchParams.delete( 'prpl_show_all_recommendations' ); - } else { - url.searchParams.set( 'prpl_show_all_recommendations', '' ); - } - history.pushState( {}, '', url ); - } ); -} ); - -/* eslint-enable camelcase */ diff --git a/assets/js/widgets/todo.js b/assets/js/widgets/todo.js deleted file mode 100644 index 64d55a433c..0000000000 --- a/assets/js/widgets/todo.js +++ /dev/null @@ -1,336 +0,0 @@ -/* global prplSuggestedTask, prplTerms, prplL10n */ -/* - * Widget: Todo - * - * A widget that displays a todo list. - * - * Dependencies: wp-api, progress-planner/suggested-task, wp-util, wp-a11y, progress-planner/celebrate, progress-planner/suggested-task-terms, progress-planner/l10n - */ - -const prplTodoWidget = { - /** - * Get the highest `order` value from the todo items. - * - * @return {number} The highest `order` value. - */ - getHighestItemOrder: () => { - const items = document.querySelectorAll( - '#todo-list .prpl-suggested-task' - ); - let highestOrder = 0; - items.forEach( ( item ) => { - highestOrder = Math.max( - parseInt( item.getAttribute( 'data-task-order' ) ), - highestOrder - ); - } ); - return highestOrder; - }, - - /** - * Remove the "Loading..." text and resize the grid items. - */ - removeLoadingItems: () => { - // Remove the "Loading..." text. - document.querySelector( '#prpl-todo-list-loading' )?.remove(); - - // Resize the grid items. - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - }, - - /** - * Populate the todo list. - */ - populateList: () => { - // If preloaded tasks are available, inject them. - if ( 'undefined' !== typeof prplSuggestedTask.tasks ) { - // Inject the tasks. - if ( - Array.isArray( prplSuggestedTask.tasks.userTasks ) && - prplSuggestedTask.tasks.userTasks.length - ) { - prplSuggestedTask.tasks.userTasks.forEach( ( item ) => { - // Inject the items into the DOM. - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/injectItem', { - detail: { - item, - insertPosition: - 1 === item?.prpl_points - ? 'afterbegin' // Add golden task to the start of the list. - : 'beforeend', - listId: - item.status === 'publish' - ? 'todo-list' - : 'todo-list-completed', - }, - } ) - ); - prplSuggestedTask.injectedItemIds.push( item.id ); - } ); - } - prplTodoWidget.removeLoadingItems(); - } else { - // Otherwise, inject tasks from the API. - prplSuggestedTask - .fetchItems( { - provider: 'user', - status: [ 'publish', 'trash' ], - per_page: 100, - } ) - .then( ( data ) => { - if ( ! data.length ) { - return data; - } - - // Inject the items into the DOM. - data.forEach( ( item ) => { - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/injectItem', { - detail: { - item, - insertPosition: - 1 === item?.prpl_points - ? 'afterbegin' // Add golden task to the start of the list. - : 'beforeend', - listId: - item.status === 'publish' - ? 'todo-list' - : 'todo-list-completed', - }, - } ) - ); - prplSuggestedTask.injectedItemIds.push( item.id ); - } ); - - return data; - } ) - .then( () => prplTodoWidget.removeLoadingItems() ); - } - - // When the '#create-todo-item' form is submitted, - // add a new todo item to the list - document - .getElementById( 'create-todo-item' ) - ?.addEventListener( 'submit', ( event ) => { - event.preventDefault(); - - // Add the loader. - prplTodoWidget.addLoader(); - - // Create a new post - const post = new wp.api.models.Prpl_recommendations( { - // Set the post title. - title: document.getElementById( 'new-todo-content' ).value, - status: 'publish', - // Set the `prpl_recommendations_provider` term. - prpl_recommendations_provider: - prplTerms.get( 'provider' ).user.id, - menu_order: prplTodoWidget.getHighestItemOrder() + 1, - } ); - post.save().then( ( response ) => { - if ( ! response.id ) { - return; - } - const newTask = { - ...response, - meta: { - prpl_url: '', - ...( response.meta || {} ), - }, - provider: 'user', - order: prplTodoWidget.getHighestItemOrder() + 1, - prpl_points: 0, - }; - - // Inject the new task into the DOM. - document.dispatchEvent( - new CustomEvent( 'prpl/suggestedTask/injectItem', { - detail: { - item: newTask, - insertPosition: - 1 === newTask.points - ? 'afterbegin' - : 'beforeend', // Add golden task to the start of the list. - listId: 'todo-list', - }, - } ) - ); - - // Remove the loader. - prplTodoWidget.removeLoader(); - - // Announce to screen readers. - prplTodoWidget.announceToScreenReader( - prplL10n( 'taskAddedSuccessfully' ) - ); - - // Resize the grid items. - window.dispatchEvent( - new CustomEvent( 'prpl/grid/resize' ) - ); - } ); - - // Clear the new task input element. - document.getElementById( 'new-todo-content' ).value = ''; - - // Focus the new task input element. - document.getElementById( 'new-todo-content' ).focus(); - } ); - }, - - /** - * Announce to screen readers. - * - * @param {string} message The message to announce. - * @param {string} priority The priority ('polite' or 'assertive'). - */ - announceToScreenReader: ( message, priority = 'polite' ) => { - // Use WordPress a11y speak if available. - if ( 'undefined' !== typeof wp && wp.a11y && wp.a11y.speak ) { - wp.a11y.speak( message, priority ); - } else { - // Fallback to ARIA live region. - const liveRegion = document.getElementById( - 'todo-aria-live-region' - ); - if ( liveRegion ) { - liveRegion.textContent = message; - setTimeout( () => { - liveRegion.textContent = ''; - }, 1000 ); - } - } - }, - - /** - * Add the loader. - */ - addLoader: () => { - const loader = document.createElement( 'span' ); - const loadingTasksText = prplL10n( 'loadingTasks' ); - loader.className = 'prpl-loader'; - loader.setAttribute( 'role', 'status' ); - loader.setAttribute( 'aria-live', 'polite' ); - loader.innerHTML = `${ loadingTasksText }`; - document.getElementById( 'todo-list' ).appendChild( loader ); - }, - - /** - * Remove the loader. - */ - removeLoader: () => { - document.querySelector( '#todo-list .prpl-loader' )?.remove(); - }, - - /** - * Show the delete all popover. - */ - showDeleteAllPopover: () => { - document - .getElementById( 'todo-list-completed-delete-all-popover' ) - .showPopover(); - }, - - /** - * Close the delete all popover. - */ - closeDeleteAllPopover: () => { - document - .getElementById( 'todo-list-completed-delete-all-popover' ) - .hidePopover(); - }, - - /** - * Delete all completed tasks and close the popover. - */ - deleteAllCompletedTasksAndClosePopover: () => { - prplTodoWidget.deleteAllCompletedTasks(); - prplTodoWidget.closeDeleteAllPopover(); - }, - - /** - * Delete all completed tasks. - */ - deleteAllCompletedTasks: () => { - const items = document.querySelectorAll( - '#todo-list-completed .prpl-suggested-task' - ); - const itemCount = items.length; - - items.forEach( ( item ) => { - const postId = parseInt( item.getAttribute( 'data-post-id' ) ); - prplSuggestedTask.trash( postId ); - } ); - - // Announce to screen readers. - const tasksWord = - itemCount === 1 - ? prplL10n( 'taskDeleted' ) - : prplL10n( 'tasksDeleted' ); - prplTodoWidget.announceToScreenReader( - `${ itemCount } ${ tasksWord }`, - 'assertive' - ); - - // Resize event will be triggered by the trash function. - }, -}; - -document - .getElementById( 'todo-list-completed-details' ) - ?.addEventListener( 'toggle', () => { - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - } ); - -// Add event listener for delete all button. -document - .getElementById( 'todo-list-completed-delete-all' ) - ?.addEventListener( 'click', () => { - prplTodoWidget.showDeleteAllPopover(); - } ); - -// Add event listener for cancel button in delete all popover. -document - .getElementById( 'todo-list-completed-delete-all-cancel' ) - ?.addEventListener( 'click', () => { - prplTodoWidget.closeDeleteAllPopover(); - } ); - -// Add event listener for confirm button in delete all popover. -document - .getElementById( 'todo-list-completed-delete-all-confirm' ) - ?.addEventListener( 'click', () => { - prplTodoWidget.deleteAllCompletedTasksAndClosePopover(); - } ); - -document.addEventListener( 'prpl/suggestedTask/itemInjected', ( event ) => { - if ( 'todo-list' !== event.detail.listId ) { - return; - } - setTimeout( () => { - // Get all items in the list. - const items = document.querySelectorAll( - `#${ event.detail.listId } .prpl-suggested-task` - ); - - // Reorder items based on their `data-task-order` attribute. - const orderedItems = Array.from( items ).sort( ( a, b ) => { - return ( - parseInt( a.getAttribute( 'data-task-order' ) ) - - parseInt( b.getAttribute( 'data-task-order' ) ) - ); - } ); - - // Remove all items from the list. - items.forEach( ( item ) => item.remove() ); - - // Inject the ordered items back into the list. - orderedItems.forEach( ( item ) => - document.getElementById( event.detail.listId ).appendChild( item ) - ); - - // Resize the grid items. - window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) ); - } ); -} ); diff --git a/assets/js/yoast-focus-element.js b/assets/js/yoast-focus-element.js deleted file mode 100644 index e35cda92e7..0000000000 --- a/assets/js/yoast-focus-element.js +++ /dev/null @@ -1,263 +0,0 @@ -/* global progressPlannerYoastFocusElement, MutationObserver */ -/** - * yoast-focus-element script. - * - */ - -/** - * Yoast Focus Element class. - */ -class ProgressPlannerYoastFocus { - /** - * Constructor. - */ - constructor() { - this.container = document.querySelector( '#yoast-seo-settings' ); - this.tasks = progressPlannerYoastFocusElement.tasks; - this.baseUrl = progressPlannerYoastFocusElement.base_url; - - if ( this.container ) { - this.init(); - } - } - - /** - * Initialize the Yoast Focus Element. - */ - init() { - this.waitForMainAndObserveContent(); - this.observeYoastSidebarClicks(); - } - - /** - * Check if the value of the element matches the value specified in the task. - * - * @param {Element} element The element to check. - * @param {Object} task The task to check. - * @return {boolean} True if the value matches, false otherwise. - */ - checkTaskValue( element, task ) { - if ( ! task.valueElement ) { - return true; - } - - const attributeName = task.valueElement.attributeName || 'value'; - const attributeValue = task.valueElement.attributeValue; - const operator = task.valueElement.operator || '='; - const currentValue = element.getAttribute( attributeName ) || ''; - - return '!=' === operator - ? currentValue !== attributeValue - : currentValue === attributeValue; - } - - /** - * Observe the Yoast sidebar clicks. - */ - observeYoastSidebarClicks() { - const waitForNav = new MutationObserver( - ( mutationsList, observer ) => { - const nav = this.container.querySelector( - 'nav.yst-sidebar-navigation__sidebar' - ); - if ( nav ) { - observer.disconnect(); - - nav.addEventListener( 'click', ( e ) => { - const link = e.target.closest( 'a' ); - if ( link ) { - this.waitForMainAndObserveContent(); - } - } ); - } - } - ); - - waitForNav.observe( this.container, { - childList: true, - subtree: true, - } ); - } - - /** - * Wait for the main content to load and observe the content. - */ - waitForMainAndObserveContent() { - const waitForMain = new MutationObserver( - ( mutationsList, observer ) => { - const main = this.container.querySelector( 'main.yst-paper' ); - if ( main ) { - observer.disconnect(); - - const childObserver = new MutationObserver( - ( mutations ) => { - for ( const mutation of mutations ) { - if ( - mutation.type === 'attributes' && - mutation.attributeName === 'class' - ) { - const el = mutation.target; - if ( - el.parentElement === main && - el.classList.contains( - 'yst-opacity-100' - ) - ) { - this.processTasks( el ); - } - } - } - } - ); - - main.querySelectorAll( ':scope > *' ).forEach( - ( child ) => { - childObserver.observe( child, { - attributes: true, - attributeFilter: [ 'class' ], - } ); - } - ); - } - } - ); - - waitForMain.observe( this.container, { - childList: true, - subtree: true, - } ); - } - - /** - * Process all tasks for a given element. - * - * @param {Element} el The element to process tasks for. - */ - processTasks( el ) { - for ( const task of this.tasks ) { - const valueElement = el.querySelector( - task.valueElement.elementSelector - ); - const raviIconPositionAbsolute = true; - - if ( valueElement ) { - this.processTask( - valueElement, - task, - raviIconPositionAbsolute - ); - } - } - } - - /** - * Process a single task. - * - * @param {Element} valueElement The value element to process. - * @param {Object} task The task to process. - * @param {boolean} raviIconPositionAbsolute Whether the icon should be absolutely positioned. - */ - processTask( valueElement, task, raviIconPositionAbsolute ) { - let addIconElement = valueElement.closest( task.iconElement ); - - // Exception is the upload input field. - if ( ! addIconElement && valueElement.type === 'hidden' ) { - addIconElement = valueElement - .closest( 'fieldset' ) - .querySelector( task.iconElement ); - raviIconPositionAbsolute = false; - } - - if ( ! addIconElement ) { - return; - } - - if ( - ! addIconElement.querySelector( '[data-prpl-element="ravi-icon"]' ) - ) { - this.addIcon( - valueElement, - addIconElement, - task, - raviIconPositionAbsolute - ); - } - } - - /** - * Add icon to the element. - * - * @param {Element} valueElement The value element. - * @param {Element} addIconElement The element to add the icon to. - * @param {Object} task The task. - * @param {boolean} raviIconPositionAbsolute Whether the icon should be absolutely positioned. - */ - addIcon( valueElement, addIconElement, task, raviIconPositionAbsolute ) { - const valueMatches = this.checkTaskValue( valueElement, task ); - - // Create a new span with the class prpl-form-row-ravi. - const raviIconWrapper = document.createElement( 'span' ); - raviIconWrapper.classList.add( - 'prpl-element-awards-points-icon-wrapper' - ); - raviIconWrapper.setAttribute( 'data-prpl-element', 'ravi-icon' ); - - if ( valueMatches ) { - raviIconWrapper.classList.add( 'complete' ); - } - - // Styling for absolute positioning. - if ( raviIconPositionAbsolute ) { - addIconElement.style.position = 'relative'; - - raviIconWrapper.style.position = 'absolute'; - raviIconWrapper.style.right = '3.5rem'; - raviIconWrapper.style.top = '-7px'; - } - - raviIconWrapper.appendChild( document.createElement( 'span' ) ); - - // Create an icon image. - const iconImg = document.createElement( 'img' ); - iconImg.src = this.baseUrl + '/assets/images/icon_progress_planner.svg'; - iconImg.alt = 'Ravi'; - iconImg.width = 16; - iconImg.height = 16; - - // Append the icon image to the raviIconWrapper. - raviIconWrapper.querySelector( 'span' ).appendChild( iconImg ); - - // Add the points to the raviIconWrapper. - const pointsWrapper = document.createElement( 'span' ); - pointsWrapper.classList.add( 'prpl-form-row-points' ); - pointsWrapper.textContent = valueMatches ? '✓' : '+1'; - raviIconWrapper.appendChild( pointsWrapper ); - - // Watch for changes in aria-checked to update the icon dynamically - const valueElementObserver = new MutationObserver( () => { - const currentValueMatches = this.checkTaskValue( - valueElement, - task - ); - - if ( currentValueMatches ) { - raviIconWrapper.classList.add( 'complete' ); - pointsWrapper.textContent = '✓'; - } else { - raviIconWrapper.classList.remove( 'complete' ); - pointsWrapper.textContent = '+1'; - } - } ); - - valueElementObserver.observe( valueElement, { - attributes: true, - attributeFilter: [ task.valueElement.attributeName ], - } ); - - // Finally add the raviIconWrapper to the DOM. - addIconElement.appendChild( raviIconWrapper ); - } -} - -// Initialize the Yoast Focus Element. -new ProgressPlannerYoastFocus(); diff --git a/assets/src/__tests__/mocks/apiFetch.js b/assets/src/__tests__/mocks/apiFetch.js new file mode 100644 index 0000000000..11706a5b96 --- /dev/null +++ b/assets/src/__tests__/mocks/apiFetch.js @@ -0,0 +1,41 @@ +/** + * API Fetch Test Utilities + * + * Helpers for mocking @wordpress/api-fetch in tests. + */ + +import apiFetch from '@wordpress/api-fetch'; + +/** + * Mock successful API response. + * + * @param {*} data - Data to return from the mock. + */ +export function mockApiFetchSuccess( data ) { + apiFetch.mockResolvedValueOnce( data ); +} + +/** + * Mock API error. + * + * @param {string} error - Error message. + */ +export function mockApiFetchError( error ) { + apiFetch.mockRejectedValueOnce( new Error( error ) ); +} + +/** + * Reset all API fetch mocks. + */ +export function resetApiFetchMocks() { + apiFetch.mockReset(); +} + +/** + * Get the calls made to apiFetch. + * + * @return {Array} Array of call arguments. + */ +export function getApiFetchCalls() { + return apiFetch.mock.calls; +} diff --git a/assets/src/__tests__/mocks/apiFetchCache.js b/assets/src/__tests__/mocks/apiFetchCache.js new file mode 100644 index 0000000000..1c42c2ccad --- /dev/null +++ b/assets/src/__tests__/mocks/apiFetchCache.js @@ -0,0 +1,7 @@ +const apiFetch = require( '@wordpress/api-fetch' ); + +module.exports = { + cachedApiFetch: ( options ) => apiFetch( options ), + clearCache: jest.fn(), + clearCacheFor: jest.fn(), +}; diff --git a/assets/src/__tests__/mocks/html-entities.js b/assets/src/__tests__/mocks/html-entities.js new file mode 100644 index 0000000000..d376b80516 --- /dev/null +++ b/assets/src/__tests__/mocks/html-entities.js @@ -0,0 +1,13 @@ +module.exports = { + decodeEntities: ( text ) => { + if ( typeof text !== 'string' ) { + return text; + } + return text + .replace( /&/g, '&' ) + .replace( /</g, '<' ) + .replace( />/g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, "'" ); + }, +}; diff --git a/assets/src/__tests__/setup.js b/assets/src/__tests__/setup.js new file mode 100644 index 0000000000..0f066dd54c --- /dev/null +++ b/assets/src/__tests__/setup.js @@ -0,0 +1,60 @@ +/** + * Test Setup File + * + * Configures global mocks for WordPress packages and browser globals. + */ + +// Add jest-dom matchers +import '@testing-library/jest-dom'; + +// Mock @wordpress/api-fetch +jest.mock( '@wordpress/api-fetch', () => { + const mockApiFetch = jest.fn(); + mockApiFetch.use = jest.fn(); + mockApiFetch.createNonceMiddleware = jest.fn(); + mockApiFetch.createRootURLMiddleware = jest.fn(); + return mockApiFetch; +} ); + +// Mock @wordpress/i18n +jest.mock( '@wordpress/i18n', () => ( { + __: ( text ) => text, + _x: ( text ) => text, + _n: ( single, plural, count ) => ( count === 1 ? single : plural ), + _nx: ( single, plural, count ) => ( count === 1 ? single : plural ), + sprintf: ( format, ...args ) => { + let i = 0; + return format.replace( /%[sd]/g, () => args[ i++ ] ); + }, +} ) ); + +// Mock @wordpress/hooks +jest.mock( '@wordpress/hooks', () => ( { + applyFilters: ( hookName, value ) => value, + addFilter: jest.fn(), + removeFilter: jest.fn(), + doAction: jest.fn(), + addAction: jest.fn(), + removeAction: jest.fn(), + hasAction: jest.fn( () => false ), + hasFilter: jest.fn( () => false ), +} ) ); + +// Global window mocks for WordPress localized data +Object.assign( global.window, { + progressPlannerBadge: { + remoteServerRootUrl: 'https://progressplanner.com', + placeholderImageUrl: '/placeholder.svg', + }, + prplCelebrate: { + raviIconUrl: '/ravi.svg', + monthIconUrl: '/month.svg', + contentIconUrl: '/content.svg', + maintenanceIconUrl: '/maintenance.svg', + }, + prplDashboardConfig: { + restUrl: '/wp-json/', + nonce: 'test-nonce', + ajaxUrl: '/wp-admin/admin-ajax.php', + }, +} ); diff --git a/assets/src/__tests__/testUtils.js b/assets/src/__tests__/testUtils.js new file mode 100644 index 0000000000..b86a9d2fdc --- /dev/null +++ b/assets/src/__tests__/testUtils.js @@ -0,0 +1,124 @@ +/** + * Test Utilities and Factory Functions + * + * Helpers for creating consistent test data. + */ + +/** + * Create a mock task object. + * + * @param {Object} overrides - Properties to override. + * @return {Object} Mock task object. + */ +export function createMockTask( overrides = {} ) { + return { + id: Math.floor( Math.random() * 1000 ), + title: { rendered: 'Test Task' }, + slug: 'test-task', + status: 'publish', + menu_order: 0, + prpl_points: 5, + prpl_provider: { slug: 'test-provider' }, + ...overrides, + }; +} + +/** + * Create a mock activity object. + * + * @param {Object} overrides - Properties to override. + * @return {Object} Mock activity object. + */ +export function createMockActivity( overrides = {} ) { + return { + category: 'content', + type: 'publish', + date: new Date().toISOString().split( 'T' )[ 0 ], + points: 1, + ...overrides, + }; +} + +/** + * Create a mock badge object. + * + * @param {Object} overrides - Properties to override. + * @return {Object} Mock badge object. + */ +export function createMockBadge( overrides = {} ) { + return { + id: 'test-badge', + name: 'Test Badge', + type: 'content', + thresholds: { newPosts: 10 }, + ...overrides, + }; +} + +/** + * Create a date range for a specific month. + * + * @param {number} monthsAgo - Number of months in the past (0 = current month). + * @return {Object} Object with startDate and endDate. + */ +export function createDateRange( monthsAgo = 0 ) { + const date = new Date(); + date.setMonth( date.getMonth() - monthsAgo ); + const startDate = new Date( date.getFullYear(), date.getMonth(), 1 ); + const endDate = new Date( date.getFullYear(), date.getMonth() + 1, 0 ); + return { startDate, endDate }; +} + +/** + * Create an array of activities over consecutive days. + * + * @param {number} count - Number of activities to create. + * @param {Date} startDate - Start date for the first activity. + * @param {Object} overrides - Properties to override on each activity. + * @return {Array} Array of mock activities. + */ +export function createConsecutiveActivities( + count, + startDate = new Date(), + overrides = {} +) { + const activities = []; + for ( let i = 0; i < count; i++ ) { + const date = new Date( startDate ); + date.setDate( date.getDate() + i ); + activities.push( + createMockActivity( { + date: date.toISOString().split( 'T' )[ 0 ], + ...overrides, + } ) + ); + } + return activities; +} + +/** + * Create an array of weekly activities (one per week). + * + * @param {number} weeks - Number of weeks of activities. + * @param {Date} startDate - Start date for the first activity. + * @param {Object} overrides - Properties to override on each activity. + * @return {Array} Array of mock activities. + */ +export function createWeeklyActivities( + weeks, + startDate = new Date(), + overrides = {} +) { + const activities = []; + for ( let i = 0; i < weeks; i++ ) { + const date = new Date( startDate ); + date.setDate( date.getDate() + i * 7 ); + activities.push( + createMockActivity( { + date: date.toISOString().split( 'T' )[ 0 ], + ...overrides, + } ) + ); + } + return activities; +} diff --git a/assets/src/components/Badge/BadgeSkeleton.js b/assets/src/components/Badge/BadgeSkeleton.js new file mode 100644 index 0000000000..637434b4fb --- /dev/null +++ b/assets/src/components/Badge/BadgeSkeleton.js @@ -0,0 +1,36 @@ +/** + * Badge Skeleton Component + * + * Skeleton loading state for the Badge component. + */ + +import { SkeletonCircle } from '../Skeleton'; + +/** + * BadgeSkeleton component. + * + * @param {Object} props - Component props. + * @param {string} props.size - Size of the badge (CSS value). + * @return {JSX.Element} The BadgeSkeleton component. + */ +export default function BadgeSkeleton( { size = '100%' } ) { + const containerStyle = { + maxWidth: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }; + + return ( +
+ +
+ ); +} diff --git a/assets/src/components/Badge/__tests__/Badge.test.js b/assets/src/components/Badge/__tests__/Badge.test.js new file mode 100644 index 0000000000..19f7f6d762 --- /dev/null +++ b/assets/src/components/Badge/__tests__/Badge.test.js @@ -0,0 +1,225 @@ +/** + * Tests for Badge Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import Badge from '../index'; + +describe( 'Badge', () => { + beforeEach( () => { + // Reset window.progressPlannerBadge + window.progressPlannerBadge = { + remoteServerRootUrl: 'https://progressplanner.com', + placeholderImageUrl: '/placeholder.svg', + }; + } ); + + describe( 'rendering', () => { + it( 'renders badge image with correct src', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img ).toHaveAttribute( 'alt', 'Daisy December' ); + expect( img.src ).toContain( 'badge_id=monthly-2024-m12' ); + } ); + + it( 'uses correct alt text from badgeName', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img ).toHaveAttribute( 'alt', 'Test Badge Name' ); + } ); + + it( 'uses default alt text when badgeName is null string', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img ).toHaveAttribute( 'alt', 'Badge' ); + } ); + + it( 'uses default alt text when badgeName is falsy', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img ).toHaveAttribute( 'alt', 'Badge' ); + } ); + } ); + + describe( 'URL construction', () => { + it( 'builds correct URL with badge ID', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).toBe( + 'https://progressplanner.com/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=content-curator' + ); + } ); + + it( 'includes branding ID in URL when provided', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).toContain( 'branding_id=123' ); + } ); + + it( 'does not include branding ID when not provided', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).not.toContain( 'branding_id' ); + } ); + + it( 'uses production URL when config points to localhost', () => { + window.progressPlannerBadge = { + remoteServerRootUrl: 'http://localhost:8888', + placeholderImageUrl: '/placeholder.svg', + }; + + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).toContain( 'https://progressplanner.com' ); + expect( img.src ).not.toContain( 'localhost' ); + } ); + + it( 'uses production URL when config points to 127.0.0.1', () => { + window.progressPlannerBadge = { + remoteServerRootUrl: 'http://127.0.0.1:8888', + placeholderImageUrl: '/placeholder.svg', + }; + + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).toContain( 'https://progressplanner.com' ); + } ); + + it( 'falls back to default URL when no config', () => { + window.progressPlannerBadge = undefined; + + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.src ).toContain( 'https://progressplanner.com' ); + } ); + } ); + + describe( 'complete/incomplete styling', () => { + it( 'applies grayscale filter when incomplete', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img.style.opacity ).toBe( '0.25' ); + expect( img.style.filter ).toBe( 'grayscale(1)' ); + } ); + + it( 'does not apply grayscale when complete', () => { + render( + + ); + + const img = screen.getByRole( 'img' ); + expect( img.style.opacity ).not.toBe( '0.25' ); + expect( img.style.filter ).not.toBe( 'grayscale(1)' ); + } ); + + it( 'defaults to complete when isComplete not provided', () => { + render( ); + + const img = screen.getByRole( 'img' ); + // Should not have incomplete styles + expect( img.style.opacity ).not.toBe( '0.25' ); + } ); + } ); + + describe( 'error handling', () => { + it( 'falls back to placeholder on image error', () => { + render( ); + + const img = screen.getByRole( 'img' ); + const originalSrc = img.src; + + // Trigger error + fireEvent.error( img ); + + expect( img.src ).toBe( 'http://localhost/placeholder.svg' ); + expect( img.src ).not.toBe( originalSrc ); + } ); + + it( 'does not set placeholder when already showing placeholder', () => { + render( ); + + const img = screen.getByRole( 'img' ); + + // First error - sets placeholder + fireEvent.error( img ); + const placeholderSrc = img.src; + + // Second error - should not change (onerror nullified) + // The handler sets onerror to null, so nothing should happen + expect( img.src ).toBe( placeholderSrc ); + } ); + + it( 'does not fallback when no placeholder URL configured', () => { + window.progressPlannerBadge = { + remoteServerRootUrl: 'https://progressplanner.com', + placeholderImageUrl: '', + }; + + render( ); + + const img = screen.getByRole( 'img' ); + const originalSrc = img.src; + + // Trigger error + fireEvent.error( img ); + + // Should still have original src since no placeholder + expect( img.src ).toBe( originalSrc ); + } ); + } ); + + describe( 'base styles', () => { + it( 'has max-width 100%', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.style.maxWidth ).toBe( '100%' ); + } ); + + it( 'has height auto', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.style.height ).toBe( 'auto' ); + } ); + + it( 'has transition for opacity and filter', () => { + render( ); + + const img = screen.getByRole( 'img' ); + expect( img.style.transition ).toContain( 'opacity' ); + expect( img.style.transition ).toContain( 'filter' ); + } ); + } ); +} ); diff --git a/assets/src/components/Badge/index.js b/assets/src/components/Badge/index.js new file mode 100644 index 0000000000..0a1e77ae9f --- /dev/null +++ b/assets/src/components/Badge/index.js @@ -0,0 +1,81 @@ +/** + * Badge Component + * + * Displays a badge image fetched from the remote SaaS server. + * Replicates the exact behavior of the web component (prpl-badge). + */ + +/** + * Badge component. + * + * @param {Object} props - Component props. + * @param {string} props.badgeId - The badge ID (e.g., "monthly-2025-m12"). + * @param {string} props.badgeName - The badge name for alt text. + * @param {number} props.brandingId - Optional branding ID. + * @param {boolean} props.isComplete - Whether the badge is complete. + * @return {JSX.Element} The Badge component. + */ +export default function Badge( { + badgeId, + badgeName, + brandingId = 0, + isComplete = true, +} ) { + // Get badge config from window.progressPlannerBadge (same as web component). + // Fallback to default remote server URL if not available (matches PHP default). + const badgeConfig = window.progressPlannerBadge || {}; + let remoteServerRootUrl = + badgeConfig.remoteServerRootUrl || 'https://progressplanner.com'; + const placeholderImageUrl = badgeConfig.placeholderImageUrl || ''; + + // If remote server URL points to localhost, use production URL instead. + // The badge-svg endpoint only exists on the remote server, not locally. + if ( + remoteServerRootUrl.includes( 'localhost' ) || + remoteServerRootUrl.includes( '127.0.0.1' ) + ) { + remoteServerRootUrl = 'https://progressplanner.com'; + } + + // Build URL exactly like web component. + let url = `${ remoteServerRootUrl }/wp-json/progress-planner-saas/v1/badge-svg/?badge_id=${ badgeId }`; + if ( brandingId ) { + url += `&branding_id=${ brandingId }`; + } + + // Use inline onerror handler (same as web component). + // Note: React's onError expects a function, but we need to replicate the inline string behavior. + // We'll use dangerouslySetInnerHTML approach or create the img element properly. + const handleError = ( e ) => { + if ( placeholderImageUrl && e.target.src !== placeholderImageUrl ) { + e.target.onerror = null; // Prevent infinite loop. + e.target.src = placeholderImageUrl; + } + }; + + // Determine badge name (same logic as web component). + const displayName = badgeName && 'null' !== badgeName ? badgeName : 'Badge'; + + // Apply styles matching the web component CSS. + // CSS handles opacity/grayscale for incomplete badges via prpl-badge[complete="false"] img, + // but since we're not using the custom element, we apply styles directly. + const imgStyle = { + maxWidth: '100%', + height: 'auto', + verticalAlign: 'bottom', + transition: 'opacity 0.3s ease-in-out, filter 0.3s ease-in-out', + ...( ! isComplete && { + opacity: 0.25, + filter: 'grayscale(1)', + } ), + }; + + return ( + { + ); +} diff --git a/assets/src/components/BadgeGrid/__tests__/BadgeGrid.test.js b/assets/src/components/BadgeGrid/__tests__/BadgeGrid.test.js new file mode 100644 index 0000000000..a49978943c --- /dev/null +++ b/assets/src/components/BadgeGrid/__tests__/BadgeGrid.test.js @@ -0,0 +1,337 @@ +/** + * Tests for BadgeGrid Component + */ + +import { render, screen } from '@testing-library/react'; +import BadgeGrid from '../index'; + +// Mock the Badge component +jest.mock( '../../Badge', () => ( { badgeId, badgeName, isComplete } ) => ( + { +) ); + +describe( 'BadgeGrid', () => { + const mockConfig = { + brandingId: 123, + }; + + const mockBadges = [ + { id: 'badge-1', name: 'First Badge', progress: 100, isComplete: true }, + { + id: 'badge-2', + name: 'Second Badge', + progress: 50, + isComplete: false, + }, + { id: 'badge-3', name: 'Third Badge', progress: 0, isComplete: false }, + ]; + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.progress-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'renders all badges', () => { + render( ); + + expect( screen.getByTestId( 'badge-badge-1' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'badge-badge-2' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'badge-badge-3' ) ).toBeInTheDocument(); + } ); + + it( 'renders badge names as labels', () => { + render( ); + + expect( screen.getByText( 'First Badge' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Second Badge' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Third Badge' ) ).toBeInTheDocument(); + } ); + + it( 'renders empty grid for empty badges array', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.progress-wrapper' ) + ).toBeInTheDocument(); + expect( container.querySelectorAll( '.prpl-badge' ) ).toHaveLength( + 0 + ); + } ); + } ); + + describe( 'badge rendering', () => { + it( 'passes correct props to Badge component', () => { + render( ); + + const firstBadge = screen.getByTestId( 'badge-badge-1' ); + expect( firstBadge ).toHaveAttribute( 'alt', 'First Badge' ); + expect( firstBadge ).toHaveAttribute( 'data-complete', 'true' ); + + const secondBadge = screen.getByTestId( 'badge-badge-2' ); + expect( secondBadge ).toHaveAttribute( 'data-complete', 'false' ); + } ); + + it( 'sets data-value attribute with progress', () => { + const { container } = render( + + ); + + const badgeItems = container.querySelectorAll( '.prpl-badge' ); + expect( badgeItems[ 0 ] ).toHaveAttribute( 'data-value', '100' ); + expect( badgeItems[ 1 ] ).toHaveAttribute( 'data-value', '50' ); + expect( badgeItems[ 2 ] ).toHaveAttribute( 'data-value', '0' ); + } ); + + it( 'uses badge id as key', () => { + const { container } = render( + + ); + + // React uses keys internally, we can verify by checking badges render correctly + expect( container.querySelectorAll( '.prpl-badge' ) ).toHaveLength( + 3 + ); + } ); + } ); + + describe( 'styling', () => { + it( 'applies grid layout', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid ).toHaveStyle( { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + } ); + } ); + + it( 'applies default background color', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid ).toHaveStyle( { + background: 'var(--prpl-background-content-badge)', + } ); + } ); + + it( 'applies custom background color', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid ).toHaveStyle( { + background: 'var(--custom-bg)', + } ); + } ); + + it( 'applies badge item styles', () => { + const { container } = render( + + ); + + const badgeItem = container.querySelector( '.prpl-badge' ); + expect( badgeItem ).toHaveStyle( { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + } ); + } ); + + it( 'applies label styles', () => { + const { container } = render( + + ); + + const label = container.querySelector( '.prpl-badge p' ); + expect( label ).toHaveStyle( { + margin: '0', + textAlign: 'center', + } ); + } ); + } ); + + describe( 'className prop', () => { + it( 'includes progress-wrapper class by default', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.progress-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'appends custom className', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid ).toHaveClass( 'custom-class' ); + } ); + + it( 'handles empty className', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid.className ).toBe( 'progress-wrapper' ); + } ); + + it( 'handles multiple custom classes', () => { + const { container } = render( + + ); + + const grid = container.querySelector( '.progress-wrapper' ); + expect( grid.className ).toContain( 'class-one' ); + expect( grid.className ).toContain( 'class-two' ); + } ); + } ); + + describe( 'single badge', () => { + it( 'renders single badge correctly', () => { + const singleBadge = [ + { + id: 'only-badge', + name: 'Only Badge', + progress: 75, + isComplete: false, + }, + ]; + + render( + + ); + + expect( + screen.getByTestId( 'badge-only-badge' ) + ).toBeInTheDocument(); + expect( screen.getByText( 'Only Badge' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'many badges', () => { + it( 'renders many badges in grid', () => { + const manyBadges = Array.from( { length: 9 }, ( _, i ) => ( { + id: `badge-${ i }`, + name: `Badge ${ i }`, + progress: i * 10, + isComplete: i > 5, + } ) ); + + const { container } = render( + + ); + + expect( container.querySelectorAll( '.prpl-badge' ) ).toHaveLength( + 9 + ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles badge with special characters in name', () => { + const specialBadges = [ + { + id: 'special', + name: "Badge's & More", + progress: 50, + isComplete: false, + }, + ]; + + render( + + ); + + expect( + screen.getByText( "Badge's & More" ) + ).toBeInTheDocument(); + } ); + + it( 'handles badge with long name', () => { + const longNameBadges = [ + { + id: 'long', + name: 'This is a very long badge name that might wrap', + progress: 50, + isComplete: false, + }, + ]; + + render( + + ); + + expect( + screen.getByText( + 'This is a very long badge name that might wrap' + ) + ).toBeInTheDocument(); + } ); + + it( 'handles zero brandingId', () => { + const zeroConfig = { brandingId: 0 }; + + const { container } = render( + + ); + + expect( container.querySelectorAll( '.prpl-badge' ) ).toHaveLength( + 3 + ); + } ); + + it( 'handles undefined progress', () => { + const badgesWithoutProgress = [ + { id: 'no-progress', name: 'No Progress', isComplete: false }, + ]; + + const { container } = render( + + ); + + // Component should render even without progress value + const badge = container.querySelector( '.prpl-badge' ); + expect( badge ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/BadgeGrid/index.js b/assets/src/components/BadgeGrid/index.js new file mode 100644 index 0000000000..92741c39d3 --- /dev/null +++ b/assets/src/components/BadgeGrid/index.js @@ -0,0 +1,74 @@ +/** + * BadgeGrid Component + * + * Displays a grid of badges with consistent styling. + * Used by ContentBadges, StreakBadges, and other badge widgets. + */ + +import Badge from '../Badge'; + +/** + * BadgeGrid component. + * + * @param {Object} props - Component props. + * @param {Array} props.badges - Array of badge objects. + * @param {Object} props.config - Badge config (brandingId). + * @param {string} props.backgroundColor - Background color CSS variable. + * @param {string} props.className - Additional CSS class name. + * @return {JSX.Element} The BadgeGrid component. + */ +export default function BadgeGrid( { + badges, + config, + backgroundColor = 'var(--prpl-background-content-badge)', + className = '', +} ) { + const gridStyle = { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + gap: 'calc(var(--prpl-gap) / 4)', + background: backgroundColor, + padding: 'calc(var(--prpl-padding) / 2)', + borderRadius: 'var(--prpl-border-radius-big)', + }; + + const badgeItemStyle = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-start', + flexWrap: 'wrap', + minWidth: 0, + }; + + const labelStyle = { + margin: 0, + fontSize: 'var(--prpl-font-size-small)', + textAlign: 'center', + lineHeight: 1.2, + }; + + return ( +
+ { badges.map( ( badge ) => ( + + +

{ badge.name }

+
+ ) ) } +
+ ); +} diff --git a/assets/src/components/BadgeProgressBar/__tests__/BadgeProgressBar.test.js b/assets/src/components/BadgeProgressBar/__tests__/BadgeProgressBar.test.js new file mode 100644 index 0000000000..5dc281af77 --- /dev/null +++ b/assets/src/components/BadgeProgressBar/__tests__/BadgeProgressBar.test.js @@ -0,0 +1,355 @@ +/** + * Tests for BadgeProgressBar Component + */ + +import { render, screen } from '@testing-library/react'; +import BadgeProgressBar from '../index'; + +// Mock the Badge component +jest.mock( '../../Badge', () => ( { badgeId, badgeName } ) => ( + { +) ); + +describe( 'BadgeProgressBar', () => { + const defaultProps = { + badgeId: 'monthly-2025-m01', + badgeName: 'January 2025', + }; + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + render( ); + + expect( screen.getByTestId( 'badge' ) ).toBeInTheDocument(); + } ); + + it( 'renders badge with correct props', () => { + render( ); + + const badge = screen.getByTestId( 'badge' ); + expect( badge ).toHaveAttribute( + 'data-badge-id', + 'monthly-2025-m01' + ); + expect( badge ).toHaveAttribute( 'alt', 'January 2025' ); + } ); + + it( 'renders points display', () => { + render( ); + + expect( screen.getByText( '5pt' ) ).toBeInTheDocument(); + } ); + + it( 'uses default points of 0', () => { + render( ); + + expect( screen.getByText( '0pt' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'progress calculation', () => { + it( 'calculates 50% progress for 5/10 points', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + expect( progressBar.style.width ).toBe( '50%' ); + } ); + + it( 'calculates 100% progress for 10/10 points', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + expect( progressBar.style.width ).toBe( '100%' ); + } ); + + it( 'calculates 0% progress for 0/10 points', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + expect( progressBar.style.width ).toBe( '0%' ); + } ); + + it( 'handles maxPoints of 0 gracefully', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + expect( progressBar.style.width ).toBe( '0%' ); + } ); + + it( 'handles points exceeding maxPoints', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + expect( progressBar.style.width ).toBe( '150%' ); + } ); + } ); + + describe( 'complete state', () => { + it( 'adds complete class when points equals maxPoints', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-badge-progress-bar--complete' ) + ).toBeInTheDocument(); + } ); + + it( 'adds complete class when points exceeds maxPoints', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-badge-progress-bar--complete' ) + ).toBeInTheDocument(); + } ); + + it( 'does not add complete class when points less than maxPoints', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-badge-progress-bar--complete' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'alert indicator', () => { + it( 'shows alert indicator when not complete', () => { + const { container } = render( + + ); + + const alert = container.querySelector( + '.prpl-badge-progress-bar__alert' + ); + expect( alert ).toBeInTheDocument(); + expect( alert.textContent ).toBe( '!' ); + } ); + + it( 'hides alert indicator when complete', () => { + const { container } = render( + + ); + + const alert = container.querySelector( + '.prpl-badge-progress-bar__alert' + ); + expect( alert ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'remaining points display', () => { + it( 'shows remaining text when accumulatedRemaining > 0', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + expect( remainingText ).toBeInTheDocument(); + } ); + + it( 'hides remaining text when accumulatedRemaining is 0', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + expect( remainingText ).not.toBeInTheDocument(); + } ); + + it( 'uses singular form for 1 day remaining', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + // The _n mock returns singular form for count=1 + expect( remainingText.innerHTML ).toContain( 'day left' ); + } ); + + it( 'uses plural form for multiple days remaining', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + expect( remainingText.innerHTML ).toContain( 'days left' ); + } ); + + it( 'displays remaining points text element', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + // Check that the remaining text element exists and has content + expect( remainingText ).toBeInTheDocument(); + expect( remainingText.innerHTML ).toContain( 'more points to go' ); + } ); + } ); + + describe( 'badge wrapper positioning', () => { + it( 'positions badge based on progress percentage', () => { + const { container } = render( + + ); + + const badgeWrapper = container.querySelector( + '.prpl-badge-progress-bar__badge-wrapper' + ); + expect( badgeWrapper.style.left ).toBe( 'calc(50% - 3.75rem)' ); + } ); + + it( 'positions badge at start for 0 progress', () => { + const { container } = render( + + ); + + const badgeWrapper = container.querySelector( + '.prpl-badge-progress-bar__badge-wrapper' + ); + expect( badgeWrapper.style.left ).toBe( 'calc(0% - 3.75rem)' ); + } ); + + it( 'positions badge at end for 100% progress', () => { + const { container } = render( + + ); + + const badgeWrapper = container.querySelector( + '.prpl-badge-progress-bar__badge-wrapper' + ); + expect( badgeWrapper.style.left ).toBe( 'calc(100% - 3.75rem)' ); + } ); + } ); + + describe( 'default props', () => { + it( 'uses default maxPoints of 10', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector( + '.prpl-badge-progress-bar__progress' + ); + // 5/10 = 50% + expect( progressBar.style.width ).toBe( '50%' ); + } ); + + it( 'uses default accumulatedRemaining of 0', () => { + const { container } = render( + + ); + + const remainingText = container.querySelector( + '.prpl-previous-month-badge-progress-bar-remaining' + ); + expect( remainingText ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/BadgeProgressBar/index.js b/assets/src/components/BadgeProgressBar/index.js new file mode 100644 index 0000000000..41b388271a --- /dev/null +++ b/assets/src/components/BadgeProgressBar/index.js @@ -0,0 +1,187 @@ +/** + * BadgeProgressBar Component + * + * Displays a progress bar with a badge icon for incomplete previous months. + */ + +import { useMemo } from '@wordpress/element'; +import { _n, sprintf } from '@wordpress/i18n'; +import Badge from '../Badge'; + +/** + * Style constants - extracted to prevent recreation on each render. + * Note: Some styles require computed values and are created in useMemo. + */ +const STYLES = { + container: { + padding: '1rem 0', + }, + bar: { + width: '100%', + height: '1rem', + backgroundColor: 'var(--prpl-color-gauge-remain)', + borderRadius: '0.5rem', + position: 'relative', + }, + progressBase: { + height: '100%', + backgroundColor: 'var(--prpl-color-monthly)', + borderRadius: '0.5rem', + transition: 'width 0.4s ease', + }, + badgeWrapperBase: { + display: 'flex', + width: '7.5rem', + height: 'auto', + position: 'absolute', + top: '-2.5rem', + transition: 'left 0.4s ease', + }, + alertIndicator: { + content: '"!"', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '20px', + height: '20px', + backgroundColor: 'var(--prpl-color-alert-error)', + border: '2px solid #fff', + borderRadius: '50%', + position: 'absolute', + top: '10%', + right: '25%', + color: '#fff', + fontSize: '12px', + fontWeight: 'bold', + }, + pointsContainer: { + display: 'flex', + justifyContent: 'flex-start', + gap: '1rem', + }, + pointsNumber: { + fontSize: 'var(--prpl-font-size-3xl)', + fontWeight: 600, + }, + remainingText: { + display: 'flex', + alignItems: 'center', + }, +}; + +/** + * BadgeProgressBar component. + * + * @param {Object} props - Component props. + * @param {string} props.badgeId - The badge ID. + * @param {string} props.badgeName - The badge name. + * @param {number} props.points - Current points. + * @param {number} props.maxPoints - Maximum points (default 10). + * @param {number} props.accumulatedRemaining - Accumulated remaining points across all badges. + * @param {number} props.daysRemaining - Days remaining in current month. + * @param {number} props.brandingId - Branding ID. + * @return {JSX.Element} The BadgeProgressBar component. + */ +export default function BadgeProgressBar( { + badgeId, + badgeName, + points = 0, + maxPoints = 10, + accumulatedRemaining = 0, + daysRemaining = 0, + brandingId = 0, +} ) { + /** + * Calculate progress percentage and derive dynamic styles. + */ + const progressPercent = useMemo( () => { + if ( maxPoints === 0 ) { + return 0; + } + return ( points / maxPoints ) * 100; + }, [ points, maxPoints ] ); + + // Dynamic styles that depend on progressPercent - memoized to prevent recreation. + const progressStyle = useMemo( + () => ( { + ...STYLES.progressBase, + width: `${ progressPercent }%`, + } ), + [ progressPercent ] + ); + + const badgeWrapperStyle = useMemo( + () => ( { + ...STYLES.badgeWrapperBase, + left: `calc(${ progressPercent }% - 3.75rem)`, + } ), + [ progressPercent ] + ); + + const isComplete = points >= maxPoints; + const className = `prpl-badge-progress-bar${ + isComplete ? ' prpl-badge-progress-bar--complete' : '' + }`; + + return ( +
+
+
+
+ + { ! isComplete && ( + + ! + + ) } +
+
+
+ + { points }pt + + { accumulatedRemaining > 0 && ( + %d', + accumulatedRemaining + ), + daysRemaining + ), + } } + /> + ) } +
+
+ ); +} diff --git a/assets/src/components/BadgeProgressInfo/__tests__/BadgeProgressInfo.test.js b/assets/src/components/BadgeProgressInfo/__tests__/BadgeProgressInfo.test.js new file mode 100644 index 0000000000..bae59a86d5 --- /dev/null +++ b/assets/src/components/BadgeProgressInfo/__tests__/BadgeProgressInfo.test.js @@ -0,0 +1,441 @@ +/** + * Tests for BadgeProgressInfo Component + */ + +import { render, screen } from '@testing-library/react'; +import BadgeProgressInfo from '../index'; + +// Mock the Gauge component +jest.mock( '../../Gauge', () => ( { children, value, backgroundColor } ) => ( +
+ { children } +
+) ); + +// Mock the Badge component +jest.mock( '../../Badge', () => ( { badgeId, badgeName, isComplete } ) => ( + { +) ); + +describe( 'BadgeProgressInfo', () => { + const mockConfig = { + brandingId: 123, + }; + + const mockBadge = { + id: 'content-curator', + name: 'Content Curator', + progress: 75, + remaining: 25, + }; + + const mockGetRemainingText = jest.fn( + ( remaining ) => `${ remaining } more items needed` + ); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-latest-badges-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'renders Gauge component', () => { + render( + + ); + + expect( screen.getByTestId( 'gauge' ) ).toBeInTheDocument(); + } ); + + it( 'renders Badge component inside Gauge', () => { + render( + + ); + + expect( screen.getByTestId( 'badge' ) ).toBeInTheDocument(); + } ); + + it( 'renders progress percentage', () => { + render( + + ); + + expect( screen.getByText( '75%' ) ).toBeInTheDocument(); + } ); + + it( 'renders progress label with badge name', () => { + render( + + ); + + expect( + screen.getByText( 'Progress Content Curator' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'props passing', () => { + it( 'passes progress value to Gauge', () => { + render( + + ); + + const gauge = screen.getByTestId( 'gauge' ); + expect( gauge ).toHaveAttribute( 'data-value', '75' ); + } ); + + it( 'passes badge id to Badge', () => { + render( + + ); + + const badge = screen.getByTestId( 'badge' ); + expect( badge ).toHaveAttribute( + 'data-badge-id', + 'content-curator' + ); + } ); + + it( 'passes badge name to Badge', () => { + render( + + ); + + const badge = screen.getByTestId( 'badge' ); + expect( badge ).toHaveAttribute( 'alt', 'Content Curator' ); + } ); + + it( 'passes isComplete as true to Badge', () => { + render( + + ); + + const badge = screen.getByTestId( 'badge' ); + expect( badge ).toHaveAttribute( 'data-complete', 'true' ); + } ); + } ); + + describe( 'getRemainingText function', () => { + it( 'calls getRemainingText with badge.remaining', () => { + render( + + ); + + expect( mockGetRemainingText ).toHaveBeenCalledWith( 25 ); + } ); + + it( 'renders remaining text from function', () => { + render( + + ); + + expect( + screen.getByText( '25 more items needed' ) + ).toBeInTheDocument(); + } ); + + it( 'handles different remaining values', () => { + const customBadge = { ...mockBadge, remaining: 100 }; + + render( + + ); + + expect( mockGetRemainingText ).toHaveBeenCalledWith( 100 ); + } ); + } ); + + describe( 'background color', () => { + it( 'uses default background color', () => { + render( + + ); + + const gauge = screen.getByTestId( 'gauge' ); + expect( gauge ).toHaveAttribute( + 'data-background', + 'var(--prpl-background-content-badge)' + ); + } ); + + it( 'uses badge.background if provided', () => { + const badgeWithBackground = { + ...mockBadge, + background: 'var(--custom-bg)', + }; + + render( + + ); + + const gauge = screen.getByTestId( 'gauge' ); + expect( gauge ).toHaveAttribute( + 'data-background', + 'var(--custom-bg)' + ); + } ); + + it( 'uses backgroundColor prop as fallback', () => { + render( + + ); + + const gauge = screen.getByTestId( 'gauge' ); + expect( gauge ).toHaveAttribute( + 'data-background', + 'var(--fallback-bg)' + ); + } ); + + it( 'badge.background takes precedence over backgroundColor prop', () => { + const badgeWithBackground = { + ...mockBadge, + background: 'var(--badge-bg)', + }; + + render( + + ); + + const gauge = screen.getByTestId( 'gauge' ); + expect( gauge ).toHaveAttribute( + 'data-background', + 'var(--badge-bg)' + ); + } ); + } ); + + describe( 'styling', () => { + it( 'renders content wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-badge-content-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'applies progress label styles', () => { + const { container } = render( + + ); + + const label = container.querySelector( + '.prpl-badge-content-wrapper p' + ); + expect( label ).toHaveStyle( { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + } ); + } ); + + it( 'applies percent style with bold font', () => { + render( + + ); + + const percentSpan = screen.getByText( '75%' ); + expect( percentSpan ).toHaveStyle( { + fontWeight: '600', + } ); + } ); + } ); + + describe( 'different progress values', () => { + it( 'handles 0% progress', () => { + const zeroBadge = { ...mockBadge, progress: 0 }; + + render( + + ); + + expect( screen.getByText( '0%' ) ).toBeInTheDocument(); + } ); + + it( 'handles 100% progress', () => { + const completeBadge = { ...mockBadge, progress: 100 }; + + render( + + ); + + expect( screen.getByText( '100%' ) ).toBeInTheDocument(); + } ); + + it( 'handles decimal progress', () => { + const decimalBadge = { ...mockBadge, progress: 33.5 }; + + render( + + ); + + expect( screen.getByText( '33.5%' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles badge with special characters in name', () => { + const specialBadge = { + ...mockBadge, + name: "Badge's & More", + }; + + render( + + ); + + expect( + screen.getByText( "Progress Badge's & More" ) + ).toBeInTheDocument(); + } ); + + it( 'handles zero remaining', () => { + const zeroRemaining = { ...mockBadge, remaining: 0 }; + const getRemainingZero = ( remaining ) => + remaining === 0 ? 'Complete!' : `${ remaining } left`; + + render( + + ); + + expect( screen.getByText( 'Complete!' ) ).toBeInTheDocument(); + } ); + + it( 'handles long badge name', () => { + const longNameBadge = { + ...mockBadge, + name: 'This is a very long badge name', + }; + + render( + + ); + + expect( + screen.getByText( 'Progress This is a very long badge name' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/BadgeProgressInfo/index.js b/assets/src/components/BadgeProgressInfo/index.js new file mode 100644 index 0000000000..d0cc1c94b7 --- /dev/null +++ b/assets/src/components/BadgeProgressInfo/index.js @@ -0,0 +1,74 @@ +/** + * BadgeProgressInfo Component + * + * Displays current badge progress with a Gauge visualization. + * Used by ContentBadges, StreakBadges, and other badge widgets. + */ + +import { __, sprintf } from '@wordpress/i18n'; +import Gauge from '../Gauge'; +import Badge from '../Badge'; + +/** + * BadgeProgressInfo component. + * + * @param {Object} props - Component props. + * @param {Object} props.badge - Current badge object. + * @param {Object} props.config - Badge config (brandingId). + * @param {string} props.backgroundColor - Background color CSS variable for gauge. + * @param {Function} props.getRemainingText - Function to get remaining text based on badge.remaining. + * @return {JSX.Element} The BadgeProgressInfo component. + */ +export default function BadgeProgressInfo( { + badge, + config, + backgroundColor = 'var(--prpl-background-content-badge)', + getRemainingText, +} ) { + const progressLabelStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '1rem', + marginBottom: 0, + }; + + const percentStyle = { + fontWeight: 600, + fontSize: 'var(--prpl-font-size-3xl)', + }; + + return ( +
+ + + +
+

+ + { sprintf( + /* translators: %s: The badge name. */ + __( 'Progress %s', 'progress-planner' ), + badge.name + ) } + + { badge.progress }% +

+

+ { getRemainingText( badge.remaining ) } +

+
+
+ ); +} diff --git a/assets/src/components/BarChart/BarChartSkeleton.js b/assets/src/components/BarChart/BarChartSkeleton.js new file mode 100644 index 0000000000..e1be9cf381 --- /dev/null +++ b/assets/src/components/BarChart/BarChartSkeleton.js @@ -0,0 +1,71 @@ +/** + * BarChart Skeleton Component + * + * Skeleton loading state for the BarChart component. + */ + +import { SkeletonRect } from '../Skeleton'; + +/** + * BarChartSkeleton component. + * + * @param {Object} props - Component props. + * @param {number} props.bars - Number of bars to show. + * @return {JSX.Element} The BarChartSkeleton component. + */ +export default function BarChartSkeleton( { bars = 6 } ) { + const containerStyle = { + display: 'flex', + maxWidth: '600px', + height: '200px', + width: '100%', + alignItems: 'flex-end', + gap: '5px', + margin: '1rem 0', + }; + + const barContainerStyle = { + flex: 'auto', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + height: '100%', + }; + + const labelContainerStyle = { + height: '1rem', + overflow: 'visible', + textAlign: 'center', + display: 'block', + width: '100%', + marginTop: '0.25rem', + }; + + // Generate random-ish heights for visual variety. + const barHeights = Array.from( { length: bars } ).map( + ( _, i ) => 30 + ( ( i * 17 ) % 50 ) + ); + + return ( +
+
+ { barHeights.map( ( height, index ) => ( +
+ + + + +
+ ) ) } +
+
+ ); +} diff --git a/assets/src/components/BarChart/__tests__/BarChart.test.js b/assets/src/components/BarChart/__tests__/BarChart.test.js new file mode 100644 index 0000000000..a6cbeb47e7 --- /dev/null +++ b/assets/src/components/BarChart/__tests__/BarChart.test.js @@ -0,0 +1,307 @@ +/** + * Tests for BarChart Component + */ + +import { render, screen } from '@testing-library/react'; +import BarChart from '../index'; + +describe( 'BarChart', () => { + const mockData = [ + { label: 'Jan', score: 80, color: '#ff0000' }, + { label: 'Feb', score: 60, color: '#00ff00' }, + { label: 'Mar', score: 90, color: '#0000ff' }, + ]; + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-bar-chart' ) + ).toBeInTheDocument(); + } ); + + it( 'renders chart-bar container', () => { + const { container } = render( ); + + expect( + container.querySelector( '.chart-bar' ) + ).toBeInTheDocument(); + } ); + + it( 'renders correct number of bars', () => { + const { container } = render( ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars ).toHaveLength( 3 ); + } ); + + it( 'renders empty chart for empty data', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-bar-chart' ) + ).toBeInTheDocument(); + expect( + container.querySelectorAll( '.prpl-bar-chart__bar' ) + ).toHaveLength( 0 ); + } ); + + it( 'uses default empty array for undefined data', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-bar-chart' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'bar styling', () => { + it( 'applies bar height based on score', () => { + const { container } = render( ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars[ 0 ] ).toHaveStyle( { height: '80%' } ); + expect( bars[ 1 ] ).toHaveStyle( { height: '60%' } ); + expect( bars[ 2 ] ).toHaveStyle( { height: '90%' } ); + } ); + + it( 'applies bar color from data', () => { + const { container } = render( ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars[ 0 ] ).toHaveStyle( { background: '#ff0000' } ); + expect( bars[ 1 ] ).toHaveStyle( { background: '#00ff00' } ); + expect( bars[ 2 ] ).toHaveStyle( { background: '#0000ff' } ); + } ); + + it( 'sets bar width to 100%', () => { + const { container } = render( ); + + const bar = container.querySelector( '.prpl-bar-chart__bar' ); + expect( bar ).toHaveStyle( { width: '100%' } ); + } ); + } ); + + describe( 'bar title attribute', () => { + it( 'sets title with label and score', () => { + const { container } = render( ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars[ 0 ] ).toHaveAttribute( 'title', 'Jan - 80%' ); + expect( bars[ 1 ] ).toHaveAttribute( 'title', 'Feb - 60%' ); + expect( bars[ 2 ] ).toHaveAttribute( 'title', 'Mar - 90%' ); + } ); + } ); + + describe( 'labels', () => { + it( 'renders labels for each bar', () => { + render( ); + + expect( screen.getByText( 'Jan' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Feb' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Mar' ) ).toBeInTheDocument(); + } ); + + it( 'makes labels visible for small datasets', () => { + const { container } = render( ); + + const labels = container.querySelectorAll( '.label' ); + labels.forEach( ( label ) => { + expect( label ).toHaveClass( 'visible' ); + } ); + } ); + + it( 'uses label-container wrapper', () => { + const { container } = render( ); + + const containers = container.querySelectorAll( '.label-container' ); + expect( containers ).toHaveLength( 3 ); + } ); + } ); + + describe( 'label visibility with many items', () => { + const manyDataPoints = Array.from( { length: 12 }, ( _, i ) => ( { + label: `Item ${ i + 1 }`, + score: ( i + 1 ) * 8, + color: '#333', + } ) ); + + it( 'renders all bars for many data points', () => { + const { container } = render( + + ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars ).toHaveLength( 12 ); + } ); + + it( 'hides some labels when there are many items', () => { + const { container } = render( + + ); + + const invisibleLabels = + container.querySelectorAll( '.label.invisible' ); + expect( invisibleLabels.length ).toBeGreaterThan( 0 ); + } ); + + it( 'keeps some labels visible', () => { + const { container } = render( + + ); + + const visibleLabels = + container.querySelectorAll( '.label.visible' ); + expect( visibleLabels.length ).toBeGreaterThan( 0 ); + expect( visibleLabels.length ).toBeLessThanOrEqual( 6 ); + } ); + } ); + + describe( 'container styling', () => { + it( 'applies flex layout to chart-bar', () => { + const { container } = render( ); + + const chartBar = container.querySelector( '.chart-bar' ); + expect( chartBar ).toHaveStyle( { + display: 'flex', + alignItems: 'flex-end', + } ); + } ); + + it( 'sets max width on chart-bar', () => { + const { container } = render( ); + + const chartBar = container.querySelector( '.chart-bar' ); + expect( chartBar ).toHaveStyle( { maxWidth: '600px' } ); + } ); + + it( 'sets height on chart-bar', () => { + const { container } = render( ); + + const chartBar = container.querySelector( '.chart-bar' ); + expect( chartBar ).toHaveStyle( { height: '200px' } ); + } ); + } ); + + describe( 'bar container styling', () => { + it( 'applies flex column layout', () => { + const { container } = render( ); + + const barContainer = container.querySelector( + '.prpl-bar-chart__bar-container' + ); + expect( barContainer ).toHaveStyle( { + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + } ); + } ); + + it( 'sets full height on bar container', () => { + const { container } = render( ); + + const barContainer = container.querySelector( + '.prpl-bar-chart__bar-container' + ); + expect( barContainer ).toHaveStyle( { height: '100%' } ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles 0% score', () => { + const zeroData = [ { label: 'Zero', score: 0, color: '#000' } ]; + + const { container } = render( ); + + const bar = container.querySelector( '.prpl-bar-chart__bar' ); + expect( bar ).toHaveStyle( { height: '0%' } ); + } ); + + it( 'handles 100% score', () => { + const fullData = [ { label: 'Full', score: 100, color: '#000' } ]; + + const { container } = render( ); + + const bar = container.querySelector( '.prpl-bar-chart__bar' ); + expect( bar ).toHaveStyle( { height: '100%' } ); + } ); + + it( 'handles special characters in label', () => { + const specialData = [ + { label: "Jan's & More", score: 50, color: '#000' }, + ]; + + render( ); + + expect( + screen.getByText( "Jan's & More" ) + ).toBeInTheDocument(); + } ); + + it( 'handles CSS variable colors', () => { + const cssVarData = [ + { label: 'Test', score: 50, color: 'var(--chart-color)' }, + ]; + + const { container } = render( ); + + const bar = container.querySelector( '.prpl-bar-chart__bar' ); + expect( bar ).toHaveStyle( { background: 'var(--chart-color)' } ); + } ); + + it( 'handles single data point', () => { + const singleData = [ { label: 'Only', score: 75, color: '#abc' } ]; + + const { container } = render( ); + + const bars = container.querySelectorAll( '.prpl-bar-chart__bar' ); + expect( bars ).toHaveLength( 1 ); + } ); + + it( 'handles decimal scores', () => { + const decimalData = [ + { label: 'Decimal', score: 33.5, color: '#000' }, + ]; + + const { container } = render( ); + + const bar = container.querySelector( '.prpl-bar-chart__bar' ); + expect( bar ).toHaveStyle( { height: '33.5%' } ); + expect( bar ).toHaveAttribute( 'title', 'Decimal - 33.5%' ); + } ); + } ); + + describe( 'exactly 6 items', () => { + it( 'shows all labels for exactly 6 items', () => { + const sixItems = Array.from( { length: 6 }, ( _, i ) => ( { + label: `Item ${ i + 1 }`, + score: 50, + color: '#333', + } ) ); + + const { container } = render( ); + + const visibleLabels = + container.querySelectorAll( '.label.visible' ); + expect( visibleLabels ).toHaveLength( 6 ); + } ); + } ); + + describe( 'exactly 7 items', () => { + it( 'shows all labels for 7 items (divider is 1)', () => { + // labelsDivider = Math.floor(7/6) = 1, so all labels visible + const sevenItems = Array.from( { length: 7 }, ( _, i ) => ( { + label: `Item ${ i + 1 }`, + score: 50, + color: '#333', + } ) ); + + const { container } = render( ); + + const visibleLabels = + container.querySelectorAll( '.label.visible' ); + expect( visibleLabels ).toHaveLength( 7 ); + } ); + } ); +} ); diff --git a/assets/src/components/BarChart/index.js b/assets/src/components/BarChart/index.js new file mode 100644 index 0000000000..c7ea2812ad --- /dev/null +++ b/assets/src/components/BarChart/index.js @@ -0,0 +1,139 @@ +/** + * BarChart Component + * + * Displays a bar chart with labels. + */ + +import { useEffect, useRef } from '@wordpress/element'; + +/** + * BarChart component. + * + * @param {Object} props - Component props. + * @param {Array} props.data - Array of data points with label, score, and color. + * @return {JSX.Element} The BarChart component. + */ +export default function BarChart( { data = [] } ) { + const chartRef = useRef( null ); + + // Calculate how many labels to show (max 6) + const labelsDivider = data.length > 6 ? Math.floor( data.length / 6 ) : 1; + + /** + * Adjust label positioning when there are many items. + */ + useEffect( () => { + if ( ! chartRef.current ) { + return; + } + + const invisibleLabels = + chartRef.current.querySelectorAll( '.label.invisible' ); + + if ( invisibleLabels.length === 0 ) { + return; + } + + const labelContainers = + chartRef.current.querySelectorAll( '.label-container' ); + const chartBar = chartRef.current.querySelector( '.chart-bar' ); + + labelContainers.forEach( ( container ) => { + const labelElement = container.querySelector( '.label' ); + if ( ! labelElement ) { + return; + } + + const labelWidth = labelElement.offsetWidth; + labelElement.style.display = 'block'; + labelElement.style.width = '0'; + + const marginLeft = ( container.offsetWidth - labelWidth ) / 2; + if ( labelElement.classList.contains( 'visible' ) ) { + labelElement.style.marginLeft = `${ marginLeft }px`; + } + } ); + + // Reduce gap between items to avoid overflows + const firstLabel = chartRef.current.querySelector( '.label' ); + if ( firstLabel && chartBar ) { + const newGap = Math.max( firstLabel.offsetWidth / 4, 1 ); + chartBar.style.gap = `${ Math.floor( newGap ) }px`; + } + }, [ data ] ); + + const containerStyle = { + display: 'flex', + maxWidth: '600px', + height: '200px', + width: '100%', + alignItems: 'flex-end', + gap: '5px', + margin: '1rem 0', + }; + + const barContainerStyle = { + flex: 'auto', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + height: '100%', + }; + + const labelContainerStyle = { + height: '1rem', + overflow: 'visible', + textAlign: 'center', + display: 'block', + width: '100%', + fontSize: '0.75em', + }; + + return ( +
+
+ { data.map( ( item, index ) => { + const barStyle = { + display: 'block', + width: '100%', + height: `${ item.score }%`, + background: item.color, + }; + + const isLabelVisible = index % labelsDivider === 0; + const labelClass = isLabelVisible + ? 'label visible' + : 'label invisible'; + const labelStyle = isLabelVisible + ? {} + : { visibility: 'hidden' }; + + return ( +
+
+ + + { item.label } + + +
+ ); + } ) } +
+
+ ); +} diff --git a/assets/src/components/BigCounter/BigCounterSkeleton.js b/assets/src/components/BigCounter/BigCounterSkeleton.js new file mode 100644 index 0000000000..b2051aa26e --- /dev/null +++ b/assets/src/components/BigCounter/BigCounterSkeleton.js @@ -0,0 +1,50 @@ +/** + * BigCounter Skeleton Component + * + * Skeleton loading state for the BigCounter component. + */ + +import { SkeletonRect } from '../Skeleton'; + +/** + * BigCounterSkeleton component. + * + * @param {Object} props - Component props. + * @param {string} props.backgroundColor - Background color (CSS value). + * @return {JSX.Element} The BigCounterSkeleton component. + */ +export default function BigCounterSkeleton( { + backgroundColor = 'var(--prpl-background-content)', +} ) { + const containerStyle = { + backgroundColor, + padding: 'var(--prpl-padding)', + borderRadius: 'var(--prpl-border-radius-big)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + alignContent: 'center', + justifyContent: 'center', + height: 'calc(var(--prpl-font-size-5xl) + var(--prpl-font-size-2xl) + var(--prpl-padding) * 2)', + marginBottom: 'var(--prpl-padding)', + gap: '0.5rem', + }; + + return ( +
+ { /* Number placeholder */ } + + { /* Label placeholder */ } + +
+ ); +} diff --git a/assets/src/components/BigCounter/__tests__/BigCounter.test.js b/assets/src/components/BigCounter/__tests__/BigCounter.test.js new file mode 100644 index 0000000000..f3ffbdd1f3 --- /dev/null +++ b/assets/src/components/BigCounter/__tests__/BigCounter.test.js @@ -0,0 +1,290 @@ +/** + * Tests for BigCounter Component + */ + +import { render, screen, act } from '@testing-library/react'; +import BigCounter from '../index'; + +describe( 'BigCounter', () => { + let originalAddEventListener; + let originalRemoveEventListener; + let resizeListeners; + + beforeEach( () => { + resizeListeners = []; + originalAddEventListener = window.addEventListener; + originalRemoveEventListener = window.removeEventListener; + + window.addEventListener = jest.fn( ( event, handler ) => { + if ( event === 'resize' ) { + resizeListeners.push( handler ); + } + originalAddEventListener.call( window, event, handler ); + } ); + + window.removeEventListener = jest.fn( ( event, handler ) => { + if ( event === 'resize' ) { + resizeListeners = resizeListeners.filter( + ( h ) => h !== handler + ); + } + originalRemoveEventListener.call( window, event, handler ); + } ); + } ); + + afterEach( () => { + window.addEventListener = originalAddEventListener; + window.removeEventListener = originalRemoveEventListener; + } ); + + describe( 'basic rendering', () => { + it( 'renders the number', () => { + render( ); + + expect( screen.getByText( '42' ) ).toBeInTheDocument(); + } ); + + it( 'renders the label', () => { + render( ); + + expect( screen.getByText( 'Active Users' ) ).toBeInTheDocument(); + } ); + + it( 'renders both number and label', () => { + render( ); + + expect( screen.getByText( '100' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Points Earned' ) ).toBeInTheDocument(); + } ); + + it( 'renders string numbers correctly', () => { + render( ); + + expect( screen.getByText( '1,234' ) ).toBeInTheDocument(); + } ); + + it( 'renders empty label', () => { + render( ); + + expect( screen.getByText( '5' ) ).toBeInTheDocument(); + } ); + + it( 'renders zero value', () => { + render( ); + + expect( screen.getByText( '0' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'styling', () => { + it( 'uses default background color', () => { + const { container } = render( + + ); + + const counter = container.querySelector( '.prpl-big-counter' ); + expect( counter ).toHaveStyle( { + backgroundColor: 'var(--prpl-background-content)', + } ); + } ); + + it( 'accepts custom background color', () => { + const { container } = render( + + ); + + const counter = container.querySelector( '.prpl-big-counter' ); + expect( counter ).toHaveStyle( { + backgroundColor: '#ff0000', + } ); + } ); + + it( 'applies flex layout to container', () => { + const { container } = render( + + ); + + const counter = container.querySelector( '.prpl-big-counter' ); + expect( counter ).toHaveStyle( { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + } ); + } ); + + it( 'applies font weight to number', () => { + const { container } = render( + + ); + + const numberElement = container.querySelector( + '.prpl-big-counter__number' + ); + expect( numberElement ).toHaveStyle( { + fontWeight: '600', + } ); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has main container class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-big-counter' ) + ).toBeInTheDocument(); + } ); + + it( 'has number class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-big-counter__number' ) + ).toBeInTheDocument(); + } ); + + it( 'has label wrapper class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-big-counter__label-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'has label class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-big-counter__label' ) + ).toBeInTheDocument(); + } ); + + it( 'has width reference class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-big-counter__width-reference' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'event listeners', () => { + it( 'adds resize event listener on mount', () => { + render( ); + + expect( window.addEventListener ).toHaveBeenCalledWith( + 'resize', + expect.any( Function ) + ); + } ); + + it( 'removes resize event listener on unmount', () => { + const { unmount } = render( + + ); + + unmount(); + + expect( window.removeEventListener ).toHaveBeenCalledWith( + 'resize', + expect.any( Function ) + ); + } ); + + it( 'handles resize events', () => { + render( ); + + // Trigger resize event should not throw + act( () => { + resizeListeners.forEach( ( listener ) => listener() ); + } ); + + // Component should still be rendered + expect( screen.getByText( '1' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'responsive behavior', () => { + it( 'sets initial font size on label', () => { + const { container } = render( + + ); + + const label = container.querySelector( '.prpl-big-counter__label' ); + // Initial style is 100% + expect( label ).toHaveStyle( { + fontSize: '100%', + } ); + } ); + + it( 'sets width reference to 100%', () => { + const { container } = render( + + ); + + const widthRef = container.querySelector( + '.prpl-big-counter__width-reference' + ); + expect( widthRef ).toHaveStyle( { + width: '100%', + } ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles very long numbers', () => { + render( ); + + expect( screen.getByText( '999,999,999,999' ) ).toBeInTheDocument(); + } ); + + it( 'handles very long labels', () => { + const longLabel = + 'This is a very long label that might need to be resized'; + render( ); + + expect( screen.getByText( longLabel ) ).toBeInTheDocument(); + } ); + + it( 'handles special characters in number', () => { + render( ); + + expect( screen.getByText( '$1,000' ) ).toBeInTheDocument(); + } ); + + it( 'handles special characters in label', () => { + render( + + ); + + expect( + screen.getByText( 'Items & Things (Special)' ) + ).toBeInTheDocument(); + } ); + + it( 'handles CSS variable as background color', () => { + const { container } = render( + + ); + + const counter = container.querySelector( '.prpl-big-counter' ); + expect( counter ).toHaveStyle( { + backgroundColor: 'var(--custom-color)', + } ); + } ); + } ); +} ); diff --git a/assets/src/components/BigCounter/index.js b/assets/src/components/BigCounter/index.js new file mode 100644 index 0000000000..466c7c08dc --- /dev/null +++ b/assets/src/components/BigCounter/index.js @@ -0,0 +1,117 @@ +/** + * BigCounter Component + * + * Displays a large counter with a label, with responsive text sizing. + */ + +import { useEffect, useRef, useCallback } from '@wordpress/element'; + +/** + * BigCounter component. + * + * @param {Object} props - Component props. + * @param {string} props.number - The number to display. + * @param {string} props.label - The label text below the number. + * @param {string} props.backgroundColor - Background color (CSS value). + * @return {JSX.Element} The BigCounter component. + */ +export default function BigCounter( { + number, + label, + backgroundColor = 'var(--prpl-background-content)', +} ) { + const containerRef = useRef( null ); + const labelRef = useRef( null ); + + const resizeFont = useCallback( () => { + const labelElement = labelRef.current; + const containerElement = containerRef.current; + + if ( ! labelElement || ! containerElement ) { + return; + } + + // Reset to 100% first + labelElement.style.fontSize = '100%'; + labelElement.style.width = 'max-content'; + + const containerWidth = containerElement.clientWidth; + let size = 100; + + // Shrink the font until it fits or reaches minimum size + while ( labelElement.clientWidth > containerWidth && size > 80 ) { + size -= 1; + labelElement.style.fontSize = size + '%'; + } + + // If we hit minimum size, set width to 100% for wrapping + if ( size <= 80 ) { + labelElement.style.fontSize = '80%'; + labelElement.style.width = '100%'; + } + }, [] ); + + useEffect( () => { + resizeFont(); + window.addEventListener( 'resize', resizeFont ); + + return () => { + window.removeEventListener( 'resize', resizeFont ); + }; + }, [ resizeFont, label ] ); + + const containerStyle = { + backgroundColor, + padding: 'var(--prpl-padding)', + borderRadius: 'var(--prpl-border-radius-big)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + alignContent: 'center', + justifyContent: 'center', + height: 'calc(var(--prpl-font-size-5xl) + var(--prpl-font-size-2xl) + var(--prpl-padding) * 2)', + marginBottom: 'var(--prpl-padding)', + }; + + const numberStyle = { + fontSize: 'var(--prpl-font-size-5xl)', + lineHeight: 1, + fontWeight: 600, + }; + + const labelWrapperStyle = { + fontSize: 'var(--prpl-font-size-2xl)', + }; + + const labelStyle = { + fontSize: '100%', + display: 'inline-block', + width: 'max-content', + }; + + return ( +
+
+ + { number } + + + + { label } + + +
+ ); +} diff --git a/assets/src/components/ConfirmDialog/index.js b/assets/src/components/ConfirmDialog/index.js new file mode 100644 index 0000000000..aeae141abf --- /dev/null +++ b/assets/src/components/ConfirmDialog/index.js @@ -0,0 +1,87 @@ +/** + * ConfirmDialog Component + * + * A reusable confirmation dialog with overlay backdrop. + * + * @param {Object} props - Component props. + * @param {boolean} props.isOpen - Whether the dialog is visible. + * @param {string} props.message - The confirmation message. + * @param {string} props.confirm - Confirm button text. + * @param {string} props.cancel - Cancel button text. + * @param {Function} props.onConfirm - Callback when confirmed. + * @param {Function} props.onCancel - Callback when cancelled. + * @return {JSX.Element|null} The dialog or null when closed. + */ + +import { __ } from '@wordpress/i18n'; +import Icon from '../Icon'; + +const STYLES = { + dialog: { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + zIndex: 10000, + background: 'white', + padding: '20px', + borderRadius: '8px', + boxShadow: '0 4px 20px rgba(0,0,0,0.15)', + }, + buttons: { + display: 'flex', + gap: '2rem', + marginTop: '15px', + }, + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0,0,0,0.3)', + zIndex: 9999, + }, +}; + +export default function ConfirmDialog( { + isOpen, + message, + confirm, + cancel, + onConfirm, + onCancel, +} ) { + if ( ! isOpen ) { + return null; + } + + return ( + <> +
+
+ + + + { message } +
+
+ + +
+
+
{ + if ( e.key === 'Enter' || e.key === ' ' ) { + onCancel(); + } + } } + /> + + ); +} diff --git a/assets/src/components/Dashboard/DashboardHeader.js b/assets/src/components/Dashboard/DashboardHeader.js new file mode 100644 index 0000000000..31116a61c8 --- /dev/null +++ b/assets/src/components/Dashboard/DashboardHeader.js @@ -0,0 +1,251 @@ +/** + * DashboardHeader Component + * + * Header component with logo, tour button, subscribe form, and range/frequency selectors. + */ + +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * DashboardHeader component. + * + * @param {Object} props - Component props. + * @param {Object} props.config - Dashboard configuration. + * @return {JSX.Element} The DashboardHeader component. + */ +export default function DashboardHeader( { config } ) { + const { + licenseKey, + branding = {}, + rangeOptions = [], + frequencyOptions = [], + } = config; + const [ range, setRange ] = useState( config.currentRange || '-6 months' ); + const [ frequency, setFrequency ] = useState( + config.currentFrequency || 'monthly' + ); + + /** + * Handle range selector change. + * @param {Event} e - Change event. + */ + const handleRangeChange = ( e ) => { + const newRange = e.target.value; + setRange( newRange ); + const url = new URL( window.location.href ); + url.searchParams.set( 'range', newRange ); + window.location.href = url.href; + }; + + /** + * Handle frequency selector change. + * @param {Event} e - Change event. + */ + const handleFrequencyChange = ( e ) => { + const newFrequency = e.target.value; + setFrequency( newFrequency ); + const url = new URL( window.location.href ); + url.searchParams.set( 'frequency', newFrequency ); + window.location.href = url.href; + }; + + // Tour button click is handled by tour.js script which is enqueued separately. + // The button just needs to exist in the DOM with the correct ID. + + return ( +
+
+ { branding.logoHtml && ( +
+ ) } +
+ +
+ + + { licenseKey === 'no-license' && ( + <> + { /* Subscribe form button - triggers popover via showPopover() */ } + + + ) } + +
+ + + + +
+
+
+ ); +} diff --git a/assets/src/components/Dashboard/DashboardWidgets.js b/assets/src/components/Dashboard/DashboardWidgets.js new file mode 100644 index 0000000000..5f9f614245 --- /dev/null +++ b/assets/src/components/Dashboard/DashboardWidgets.js @@ -0,0 +1,131 @@ +/** + * DashboardWidgets Component + * + * Renders all dashboard widgets in a grid layout. + * Widgets are registered via WordPress hooks and collected from the registry. + */ + +import { Fragment, useState, useEffect } from '@wordpress/element'; +import { addAction } from '@wordpress/hooks'; +import { getRegisteredWidgets } from '../../utils/widgetRegistry'; +import ErrorBoundary from '../ErrorBoundary'; + +/** + * Widget wrapper component. + * + * @param {Object} props - Component props. + * @param {string} props.id - Widget ID. + * @param {number} props.width - Widget width (1 or 2). + * @param {boolean} props.forceLastColumn - Force to last column. + * @param {JSX.Element} props.children - Widget content. + * @return {JSX.Element} The widget wrapper. + */ +function WidgetWrapper( { id, width = 1, forceLastColumn = false, children } ) { + // Widget-specific styles + const widgetStyles = {}; + const innerContainerStyles = {}; + + // Todo widget: padding-left: 0 on wrapper, padding-left on children + if ( id === 'todo' ) { + widgetStyles.paddingLeft = 0; + innerContainerStyles.paddingLeft = 'var(--prpl-padding)'; + } + + // Badge streak widgets: flex layout + if ( + id === 'badge-streak' || + id === 'badge-streak-content' || + id === 'badge-streak-maintenance' + ) { + widgetStyles.display = 'flex'; + widgetStyles.flexDirection = 'column'; + widgetStyles.justifyContent = 'space-between'; + } + + // Monthly badges: grid positioning for large screens + if ( id === 'monthly-badges' ) { + // Apply via media query would require CSS, but we can set base styles + // The grid positioning is handled by CSS grid auto-flow + } + + return ( +
+
+ { children } +
+
+ ); +} + +/** + * DashboardWidgets component. + * + * @return {JSX.Element} The DashboardWidgets component. + */ +export default function DashboardWidgets() { + const [ registeredWidgets, setRegisteredWidgets ] = useState( [] ); + + // Listen for widget registrations and update state + useEffect( () => { + // Get initial registered widgets (widgets may have registered before this component mounted) + setRegisteredWidgets( getRegisteredWidgets() ); + + // Listen for new widget registrations + const handleWidgetRegistration = () => { + setRegisteredWidgets( getRegisteredWidgets() ); + }; + + addAction( + 'prpl.dashboard.registerWidget', + 'progress-planner/dashboard-widgets', + handleWidgetRegistration + ); + + // Cleanup: This component doesn't need to remove the action listener + // since it's just reading from the registry + }, [] ); + + /** + * Render a widget from registry. + * + * @param {Object} widget - Widget from registry. + * @return {JSX.Element|null} The widget component. + */ + const renderWidget = ( widget ) => { + const WidgetComponent = widget.component; + + if ( ! WidgetComponent ) { + return null; + } + + return ( + + + + + + ); + }; + + return ( + + { registeredWidgets.map( ( widget ) => renderWidget( widget ) ) } + + ); +} diff --git a/assets/src/components/Dashboard/Welcome.js b/assets/src/components/Dashboard/Welcome.js new file mode 100644 index 0000000000..827890578a --- /dev/null +++ b/assets/src/components/Dashboard/Welcome.js @@ -0,0 +1,675 @@ +/** + * Welcome Component + * + * Welcome/onboarding component for users who haven't accepted privacy policy. + * Migrated from welcome.php with onboard.js and upgrade-tasks.js functionality. + */ + +import { useState, useEffect, useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Welcome component. + * + * @param {Object} props - Component props. + * @param {Object} props.config - Dashboard configuration. + * @return {JSX.Element} The Welcome component. + */ +export default function Welcome( { config } ) { + const { + onboardNonceURL = '', + onboardAPIUrl = '', + ajaxUrl = '', + nonce = '', + userFirstName = '', + userEmail = '', + siteUrl = '', + timezoneOffset = 0, + branding = {}, + } = config; + + const [ withEmail, setWithEmail ] = useState( 'yes' ); + const [ name, setName ] = useState( userFirstName ); + const [ email, setEmail ] = useState( userEmail ); + const [ privacyPolicyAccepted, setPrivacyPolicyAccepted ] = + useState( false ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + const formRef = useRef( null ); + + /** + * Load upgrade tasks on mount. + */ + useEffect( () => { + const loadUpgradeTasks = async () => { + try { + // Check if upgrade tasks popover exists in DOM (from PHP) + const upgradeTasksElement = document.getElementById( + 'prpl-popover-upgrade-tasks' + ); + if ( upgradeTasksElement ) { + // Process upgrade tasks animation + await processUpgradeTasks(); + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error loading upgrade tasks:', error ); + } + }; + + loadUpgradeTasks(); + }, [] ); + + /** + * Process upgrade tasks animation. + */ + const processUpgradeTasks = async () => { + const tasksElement = document.getElementById( 'prpl-onboarding-tasks' ); + if ( ! tasksElement ) { + return; + } + + tasksElement.style.display = 'block'; + const listItems = tasksElement.querySelectorAll( 'li' ); + const timeToWait = 1000; + + const tasks = Array.from( listItems ).map( ( li, index ) => { + return new Promise( ( resolveTask ) => { + li.classList.add( 'prpl-onboarding-task-loading' ); + + setTimeout( + () => { + const taskCompleted = + 'true' === li.dataset.prplTaskCompleted; + const classToAdd = taskCompleted + ? 'prpl-onboarding-task-completed' + : 'prpl-onboarding-task-not-completed'; + li.classList.remove( 'prpl-onboarding-task-loading' ); + li.classList.add( classToAdd ); + + // Update total points + if ( taskCompleted ) { + const totalPointsElement = document.querySelector( + '#prpl-onboarding-tasks .prpl-onboarding-tasks-total-points' + ); + if ( totalPointsElement ) { + const totalPoints = parseInt( + totalPointsElement.textContent + ); + const taskPointsElement = li.querySelector( + '.prpl-suggested-task-points' + ); + if ( taskPointsElement ) { + const taskPoints = parseInt( + taskPointsElement.textContent + ); + totalPointsElement.textContent = + totalPoints + taskPoints + 'pt'; + } + } + } + + resolveTask(); + }, + ( index + 1 ) * timeToWait + ); + } ); + } ); + + await Promise.all( tasks ); + + // Enable continue button + const continueButton = document.getElementById( + 'prpl-onboarding-continue-button' + ); + if ( continueButton ) { + continueButton.classList.remove( 'prpl-disabled' ); + } + }; + + /** + * Handle form submission. + * @param {Event} e - Form submit event. + */ + const handleSubmit = async ( e ) => { + e.preventDefault(); + + if ( ! privacyPolicyAccepted ) { + return; + } + + setIsSubmitting( true ); + + try { + // Get nonce first using fetch (onboardNonceURL is external) + const nonceFormData = new FormData(); + nonceFormData.append( 'site', siteUrl ); + const nonceResponse = await fetch( onboardNonceURL, { + method: 'POST', + body: nonceFormData, + } ).then( ( res ) => res.json() ); + + if ( nonceResponse.status === 'ok' ) { + // Prepare form data + const formData = new FormData(); + formData.append( 'nonce', nonceResponse.nonce ); + formData.append( 'name', withEmail === 'yes' ? name : '' ); + formData.append( 'email', withEmail === 'yes' ? email : '' ); + formData.append( 'site', siteUrl ); + formData.append( 'timezone_offset', timezoneOffset.toString() ); + formData.append( 'with-email', withEmail ); + + // Make API request to external URL + const response = await fetch( onboardAPIUrl, { + method: 'POST', + body: formData, + } ).then( ( res ) => res.json() ); + + // Save license key locally via WordPress AJAX + if ( response.license_key ) { + const saveFormData = new FormData(); + saveFormData.append( + 'action', + 'progress_planner_save_onboard_data' + ); + saveFormData.append( '_ajax_nonce', nonce ); + saveFormData.append( 'key', response.license_key ); + + await fetch( ajaxUrl, { + method: 'POST', + body: saveFormData, + } ); + + // Reload page + window.location.reload(); + } + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Error submitting form:', error ); + setIsSubmitting( false ); + } + }; + + /** + * Handle email preference change. + * @param {string} value - Email preference value. + */ + const handleEmailPreferenceChange = ( value ) => { + setWithEmail( value ); + }; + + // Wrapper styles (migrated from .prpl-wrap.prpl-pp-not-accepted in welcome.css) + const wrapperStyle = { + padding: 0, + backgroundColor: 'var(--prpl-background-paper)', + border: '1px solid var(--prpl-color-border)', + borderRadius: 'var(--prpl-border-radius)', + }; + + return ( +
+ { /* Inline CSS for SVG sizing - can't be done with React inline styles */ } + +
+

+ { __( + 'Welcome to the Progress Planner plugin!', + 'progress-planner' + ) } +

+ + + { branding.progressIconHtml && ( + + ) } + +
+
+
+
+
+ + { __( + 'Stay on track with weekly updates', + 'progress-planner' + ) } + +
    +
  • + { sprintf( + /* translators: %1$s: tag, %2$s: tag */ + __( + '%1$s Personalized to-dos %2$s to keep your site in great shape.', + 'progress-planner' + ), + '', + '' + ) + .split( '' ) + .map( ( part, i ) => { + if ( i === 0 ) { + return part; + } + const [ before, after ] = + part.split( '' ); + return ( + + { before } + { after } + + ); + } ) } +
  • +
  • + { sprintf( + /* translators: %1$s: tag, %2$s: tag */ + __( + '%1$s Activity stats %2$s so you can track your progress.', + 'progress-planner' + ), + '', + '' + ) + .split( '' ) + .map( ( part, i ) => { + if ( i === 0 ) { + return part; + } + const [ before, after ] = + part.split( '' ); + return ( + + { before } + { after } + + ); + } ) } +
  • +
  • + { sprintf( + /* translators: %1$s: tag, %2$s: tag */ + __( + '%1$s Helpful nudges %2$s to stay consistent with your website goals.', + 'progress-planner' + ), + '', + '' + ) + .split( '' ) + .map( ( part, i ) => { + if ( i === 0 ) { + return part; + } + const [ before, after ] = + part.split( '' ); + return ( + + { before } + { after } + + ); + } ) } +
  • +
+

+ { sprintf( + /* translators: %s: progressplanner.com link */ + __( + 'To send these updates, we will create an account for you on %s.', + 'progress-planner' + ), + 'progressplanner.com' + ) + .split( ' { + if ( i === 0 ) { + return part; + } + const [ , rest ] = part.split( '' ); + return ( + + + { part.split( '>' )[ 1 ] } + + { rest } + + ); + } ) } +

+
+
+ + { __( + 'Choose your preference:', + 'progress-planner' + ) } + +
+ + +
+
+
+ + + + +
+
+
+ +
+
+
+
+ + + +
+ + { isSubmitting && ( + + + + ) } +
+
+
+ +
+
+ ); +} diff --git a/assets/src/components/Dashboard/__tests__/Dashboard.test.js b/assets/src/components/Dashboard/__tests__/Dashboard.test.js new file mode 100644 index 0000000000..fd1d720166 --- /dev/null +++ b/assets/src/components/Dashboard/__tests__/Dashboard.test.js @@ -0,0 +1,297 @@ +/** + * Tests for Dashboard Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +// Mock dashboardStore +jest.mock( '../../../stores/dashboardStore', () => ( { + useDashboardStore: jest.fn( () => jest.fn() ), +} ) ); + +// Mock child components +jest.mock( '../DashboardHeader', () => () => ( +
DashboardHeader
+) ); + +jest.mock( '../DashboardWidgets', () => () => ( +
DashboardWidgets
+) ); + +jest.mock( '../../OnboardingWizard', () => { + const React = require( 'react' ); // eslint-disable-line import/no-extraneous-dependencies + return React.forwardRef( ( _props, ref ) => { + React.useImperativeHandle( ref, () => ( { + startOnboarding: jest.fn(), + } ) ); + return
OnboardingWizard
; + } ); +} ); + +// Import after mocks +import Dashboard from '../index'; +import { useDashboardStore } from '../../../stores/dashboardStore'; + +describe( 'Dashboard', () => { + const mockSetShouldAutoStartWizard = jest.fn(); + + const defaultConfig = { + privacyPolicyAccepted: true, + baseUrl: '/wp-content/plugins/progress-planner', + }; + + beforeEach( () => { + jest.clearAllMocks(); + useDashboardStore.mockReturnValue( mockSetShouldAutoStartWizard ); + } ); + + describe( 'when privacy policy is accepted', () => { + it( 'renders dashboard header', () => { + render( ); + + expect( + screen.getByTestId( 'dashboard-header' ) + ).toBeInTheDocument(); + } ); + + it( 'renders dashboard widgets', () => { + render( ); + + expect( + screen.getByTestId( 'dashboard-widgets' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding wizard', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-wizard' ) + ).toBeInTheDocument(); + } ); + + it( 'renders skip to content link', () => { + render( ); + + expect( + screen.getByText( 'Skip to main content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders screen reader title', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'Progress Planner' } ) + ).toBeInTheDocument(); + } ); + + it( 'has widgets container with correct ID', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-main-content' ) + ).toBeInTheDocument(); + } ); + + it( 'has widgets container with correct class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-widgets-container' ) + ).toBeInTheDocument(); + } ); + + it( 'does not call setShouldAutoStartWizard', () => { + render( ); + + expect( mockSetShouldAutoStartWizard ).not.toHaveBeenCalledWith( + true + ); + } ); + } ); + + describe( 'when privacy policy is not accepted', () => { + const configNotAccepted = { + ...defaultConfig, + privacyPolicyAccepted: false, + }; + + it( 'renders start onboarding button', () => { + render( ); + + expect( + screen.getByText( 'Are you ready to work on your site?' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding graphic container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-start-onboarding-container' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding graphic image', () => { + const { container } = render( + + ); + + const img = container.querySelector( 'img' ); + expect( img ).toHaveAttribute( + 'src', + expect.stringContaining( 'thumbs_up_ravi_rtl.svg' ) + ); + } ); + + it( 'does not render dashboard header', () => { + render( ); + + expect( + screen.queryByTestId( 'dashboard-header' ) + ).not.toBeInTheDocument(); + } ); + + it( 'does not render dashboard widgets', () => { + render( ); + + expect( + screen.queryByTestId( 'dashboard-widgets' ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders onboarding wizard', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-wizard' ) + ).toBeInTheDocument(); + } ); + + it( 'calls setShouldAutoStartWizard with true', () => { + render( ); + + expect( mockSetShouldAutoStartWizard ).toHaveBeenCalledWith( true ); + } ); + + it( 'start button has correct class', () => { + render( ); + + const button = screen.getByRole( 'button' ); + expect( button ).toHaveClass( 'prpl-button-primary' ); + } ); + + it( 'start button has correct ID', () => { + render( ); + + const button = screen.getByRole( 'button' ); + expect( button ).toHaveAttribute( + 'id', + 'prpl-start-onboarding-button' + ); + } ); + } ); + + describe( 'skip link accessibility', () => { + it( 'skip link has correct href', () => { + render( ); + + const skipLink = screen.getByText( 'Skip to main content' ); + expect( skipLink ).toHaveAttribute( 'href', '#prpl-main-content' ); + } ); + + it( 'skip link has screen reader text class', () => { + render( ); + + const skipLink = screen.getByText( 'Skip to main content' ); + expect( skipLink ).toHaveClass( 'screen-reader-text' ); + } ); + + it( 'skip link becomes visible on focus', () => { + render( ); + + const skipLink = screen.getByText( 'Skip to main content' ); + fireEvent.focus( skipLink ); + + expect( skipLink ).toHaveStyle( { top: '10px' } ); + } ); + + it( 'skip link becomes hidden on blur', () => { + render( ); + + const skipLink = screen.getByText( 'Skip to main content' ); + fireEvent.focus( skipLink ); + fireEvent.blur( skipLink ); + + expect( skipLink ).toHaveStyle( { top: '-40px' } ); + } ); + } ); + + describe( 'config handling', () => { + it( 'handles missing privacyPolicyAccepted', () => { + const configWithoutPrivacy = { baseUrl: '/test' }; + + render( ); + + // Should default to not accepted + expect( + screen.getByText( 'Are you ready to work on your site?' ) + ).toBeInTheDocument(); + } ); + + it( 'handles empty config', () => { + render( ); + + // Should default to not accepted + expect( + screen.getByText( 'Are you ready to work on your site?' ) + ).toBeInTheDocument(); + } ); + + it( 'uses baseUrl for image path', () => { + const configWithBaseUrl = { + ...defaultConfig, + privacyPolicyAccepted: false, + baseUrl: '/custom/path', + }; + + const { container } = render( + + ); + + const img = container.querySelector( 'img' ); + expect( img ).toHaveAttribute( + 'src', + expect.stringContaining( '/custom/path' ) + ); + } ); + + it( 'handles missing baseUrl', () => { + const configWithoutBaseUrl = { + privacyPolicyAccepted: false, + }; + + const { container } = render( + + ); + + const img = container.querySelector( 'img' ); + expect( img ).toHaveAttribute( + 'src', + expect.stringContaining( 'thumbs_up_ravi_rtl.svg' ) + ); + } ); + } ); +} ); diff --git a/assets/src/components/Dashboard/__tests__/DashboardHeader.test.js b/assets/src/components/Dashboard/__tests__/DashboardHeader.test.js new file mode 100644 index 0000000000..6e7f36b066 --- /dev/null +++ b/assets/src/components/Dashboard/__tests__/DashboardHeader.test.js @@ -0,0 +1,516 @@ +/** + * Tests for DashboardHeader Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import DashboardHeader from '../DashboardHeader'; + +// Mock WordPress i18n +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +describe( 'DashboardHeader', () => { + const mockConfig = { + licenseKey: 'valid-license', + branding: { + logoHtml: 'Logo', + tourIconHtml: '', + registerIconHtml: '', + }, + rangeOptions: [ + { value: '-1 month', label: 'Last month' }, + { value: '-3 months', label: 'Last 3 months' }, + { value: '-6 months', label: 'Last 6 months' }, + ], + frequencyOptions: [ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, + ], + currentRange: '-6 months', + currentFrequency: 'monthly', + }; + + // Store original location + const originalLocation = window.location; + + beforeEach( () => { + jest.clearAllMocks(); + + // Mock window.location + delete window.location; + window.location = { + href: 'http://localhost/wp-admin/admin.php?page=progress-planner', + }; + + // Mock wp.hooks + window.wp = { + hooks: { + doAction: jest.fn(), + }, + }; + } ); + + afterEach( () => { + window.location = originalLocation; + delete window.wp; + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-header' ) + ).toBeInTheDocument(); + } ); + + it( 'renders logo container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-header-logo' ) + ).toBeInTheDocument(); + } ); + + it( 'renders logo HTML when provided', () => { + const { container } = render( + + ); + + const logoContainer = + container.querySelector( '.prpl-header-logo' ); + expect( logoContainer.querySelector( 'img' ) ).toBeInTheDocument(); + } ); + + it( 'renders header right section', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-header-right' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'tour button', () => { + it( 'renders tour button', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-start-tour-icon-button' ) + ).toBeInTheDocument(); + } ); + + it( 'tour button has correct type', () => { + const { container } = render( + + ); + + const button = container.querySelector( + '#prpl-start-tour-icon-button' + ); + expect( button ).toHaveAttribute( 'type', 'button' ); + } ); + + it( 'tour button has prpl-info-icon class', () => { + const { container } = render( + + ); + + const button = container.querySelector( + '#prpl-start-tour-icon-button' + ); + expect( button ).toHaveClass( 'prpl-info-icon' ); + } ); + + it( 'renders tour icon HTML when provided', () => { + const { container } = render( + + ); + + const button = container.querySelector( + '#prpl-start-tour-icon-button' + ); + expect( button.querySelector( '.tour-icon' ) ).toBeInTheDocument(); + } ); + + it( 'renders screen reader text for tour button', () => { + render( ); + + expect( screen.getByText( 'Start tour' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'subscribe button', () => { + it( 'does not render subscribe button when license is valid', () => { + render( ); + + expect( screen.queryByText( 'Subscribe' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders subscribe button when licenseKey is no-license', () => { + const configNoLicense = { + ...mockConfig, + licenseKey: 'no-license', + }; + + render( ); + + expect( screen.getByText( 'Subscribe' ) ).toBeInTheDocument(); + } ); + + it( 'renders register icon when licenseKey is no-license', () => { + const configNoLicense = { + ...mockConfig, + licenseKey: 'no-license', + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.register-icon' ) + ).toBeInTheDocument(); + } ); + + it( 'calls wp.hooks.doAction when subscribe button clicked', () => { + const configNoLicense = { + ...mockConfig, + licenseKey: 'no-license', + }; + + render( ); + + const subscribeButton = screen + .getByText( 'Subscribe' ) + .closest( 'button' ); + fireEvent.click( subscribeButton ); + + expect( window.wp.hooks.doAction ).toHaveBeenCalledWith( + 'prpl.popover.open', + 'subscribe-form', + expect.objectContaining( { + id: 'subscribe-form', + slug: 'subscribe-form', + } ) + ); + } ); + + it( 'uses fallback showPopover when wp.hooks not available', () => { + const configNoLicense = { + ...mockConfig, + licenseKey: 'no-license', + }; + + // Remove wp.hooks + delete window.wp; + + // Create mock popover element + const mockPopover = document.createElement( 'div' ); + mockPopover.id = 'prpl-popover-subscribe-form'; + mockPopover.showPopover = jest.fn(); + document.body.appendChild( mockPopover ); + + render( ); + + const subscribeButton = screen + .getByText( 'Subscribe' ) + .closest( 'button' ); + fireEvent.click( subscribeButton ); + + expect( mockPopover.showPopover ).toHaveBeenCalled(); + + // Cleanup + document.body.removeChild( mockPopover ); + } ); + } ); + + describe( 'range selector', () => { + it( 'renders range select', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-select-range' ) + ).toBeInTheDocument(); + } ); + + it( 'renders range options', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-range' ); + const options = select.querySelectorAll( 'option' ); + + expect( options ).toHaveLength( 3 ); + } ); + + it( 'has correct initial value from config', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-range' ); + expect( select.value ).toBe( '-6 months' ); + } ); + + it( 'updates URL on range change', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-range' ); + fireEvent.change( select, { target: { value: '-1 month' } } ); + + expect( window.location.href ).toContain( 'range=-1+month' ); + } ); + + it( 'has accessible label', () => { + render( ); + + expect( + screen.getByLabelText( 'Select range:' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'frequency selector', () => { + it( 'renders frequency select', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-select-frequency' ) + ).toBeInTheDocument(); + } ); + + it( 'renders frequency options', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-frequency' ); + const options = select.querySelectorAll( 'option' ); + + expect( options ).toHaveLength( 3 ); + } ); + + it( 'has correct initial value from config', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-frequency' ); + expect( select.value ).toBe( 'monthly' ); + } ); + + it( 'updates URL on frequency change', () => { + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-frequency' ); + fireEvent.change( select, { target: { value: 'weekly' } } ); + + expect( window.location.href ).toContain( 'frequency=weekly' ); + } ); + + it( 'has accessible label', () => { + render( ); + + expect( + screen.getByLabelText( 'Select frequency:' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'default values', () => { + it( 'uses default range when currentRange not in config', () => { + const configWithoutRange = { + ...mockConfig, + currentRange: undefined, + }; + + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-range' ); + expect( select.value ).toBe( '-6 months' ); + } ); + + it( 'uses default frequency when currentFrequency not in config', () => { + const configWithoutFrequency = { + ...mockConfig, + currentFrequency: undefined, + }; + + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-frequency' ); + expect( select.value ).toBe( 'monthly' ); + } ); + + it( 'handles empty rangeOptions', () => { + const configEmptyOptions = { + ...mockConfig, + rangeOptions: [], + }; + + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-range' ); + expect( select.querySelectorAll( 'option' ) ).toHaveLength( 0 ); + } ); + + it( 'handles empty frequencyOptions', () => { + const configEmptyOptions = { + ...mockConfig, + frequencyOptions: [], + }; + + const { container } = render( + + ); + + const select = container.querySelector( '#prpl-select-frequency' ); + expect( select.querySelectorAll( 'option' ) ).toHaveLength( 0 ); + } ); + + it( 'handles missing branding object', () => { + const configNoBranding = { + ...mockConfig, + branding: undefined, + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-header' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'logo rendering', () => { + it( 'does not render logo when logoHtml is empty', () => { + const configNoLogo = { + ...mockConfig, + branding: { + ...mockConfig.branding, + logoHtml: '', + }, + }; + + const { container } = render( + + ); + + const logoContainer = + container.querySelector( '.prpl-header-logo' ); + expect( logoContainer.innerHTML ).toBe( '' ); + } ); + + it( 'does not render logo when logoHtml is null', () => { + const configNoLogo = { + ...mockConfig, + branding: { + ...mockConfig.branding, + logoHtml: null, + }, + }; + + const { container } = render( + + ); + + const logoContainer = + container.querySelector( '.prpl-header-logo' ); + expect( logoContainer.innerHTML ).toBe( '' ); + } ); + } ); + + describe( 'tour icon rendering', () => { + it( 'does not render tour icon when tourIconHtml is empty', () => { + const configNoTourIcon = { + ...mockConfig, + branding: { + ...mockConfig.branding, + tourIconHtml: '', + }, + }; + + const { container } = render( + + ); + + const tourButton = container.querySelector( + '#prpl-start-tour-icon-button' + ); + expect( + tourButton.querySelector( '.tour-icon' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has prpl-header-select-range wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-header-select-range' ) + ).toBeInTheDocument(); + } ); + + it( 'screen reader labels have correct class', () => { + const { container } = render( + + ); + + const srLabels = container.querySelectorAll( + '.screen-reader-text' + ); + expect( srLabels.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'inline styles', () => { + it( 'header has flex display', () => { + const { container } = render( + + ); + + const header = container.querySelector( '.prpl-header' ); + expect( header.style.display ).toBe( 'flex' ); + } ); + + it( 'header has margin bottom', () => { + const { container } = render( + + ); + + const header = container.querySelector( '.prpl-header' ); + expect( header.style.marginBottom ).toBe( '2rem' ); + } ); + } ); +} ); diff --git a/assets/src/components/Dashboard/__tests__/DashboardWidgets.test.js b/assets/src/components/Dashboard/__tests__/DashboardWidgets.test.js new file mode 100644 index 0000000000..8c61e9bed6 --- /dev/null +++ b/assets/src/components/Dashboard/__tests__/DashboardWidgets.test.js @@ -0,0 +1,429 @@ +/** + * Tests for DashboardWidgets Component + */ + +import { render, screen, act } from '@testing-library/react'; +import DashboardWidgets from '../DashboardWidgets'; + +// Mock WordPress hooks +jest.mock( '@wordpress/hooks', () => ( { + addAction: jest.fn(), +} ) ); + +// Mock widgetRegistry +jest.mock( '../../../utils/widgetRegistry', () => ( { + getRegisteredWidgets: jest.fn( () => [] ), +} ) ); + +// Mock ErrorBoundary +jest.mock( '../../ErrorBoundary', () => ( { children, widgetName } ) => ( +
{ children }
+) ); + +describe( 'DashboardWidgets', () => { + const { addAction } = require( '@wordpress/hooks' ); + const { getRegisteredWidgets } = require( '../../../utils/widgetRegistry' ); + + beforeEach( () => { + jest.clearAllMocks(); + getRegisteredWidgets.mockReturnValue( [] ); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( ); + + expect( container ).toBeInTheDocument(); + } ); + + it( 'renders empty when no widgets registered', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-widget-wrapper' ) + ).not.toBeInTheDocument(); + } ); + + it( 'calls getRegisteredWidgets on mount', () => { + render( ); + + expect( getRegisteredWidgets ).toHaveBeenCalled(); + } ); + + it( 'registers action listener on mount', () => { + render( ); + + expect( addAction ).toHaveBeenCalledWith( + 'prpl.dashboard.registerWidget', + 'progress-planner/dashboard-widgets', + expect.any( Function ) + ); + } ); + } ); + + describe( 'widget rendering', () => { + it( 'renders registered widgets', () => { + const MockWidget = () =>
Mock Widget Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'test-widget', + title: 'Test Widget', + component: MockWidget, + }, + ] ); + + render( ); + + expect( + screen.getByText( 'Mock Widget Content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders multiple widgets', () => { + const MockWidget1 = () =>
Widget 1
; + const MockWidget2 = () =>
Widget 2
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'widget-1', + title: 'Widget 1', + component: MockWidget1, + }, + { + id: 'widget-2', + title: 'Widget 2', + component: MockWidget2, + }, + ] ); + + render( ); + + expect( screen.getByText( 'Widget 1' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Widget 2' ) ).toBeInTheDocument(); + } ); + + it( 'does not render widget without component', () => { + getRegisteredWidgets.mockReturnValue( [ + { + id: 'empty-widget', + title: 'Empty Widget', + component: null, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.prpl-empty-widget' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'widget wrapper', () => { + it( 'creates wrapper with widget ID class', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'my-widget', + title: 'My Widget', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.prpl-my-widget' ) + ).toBeInTheDocument(); + } ); + + it( 'applies width class from widget config', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'wide-widget', + title: 'Wide Widget', + component: MockWidget, + width: 2, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.prpl-widget-width-2' ) + ).toBeInTheDocument(); + } ); + + it( 'defaults to width 1', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'default-widget', + title: 'Default Widget', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.prpl-widget-width-1' ) + ).toBeInTheDocument(); + } ); + + it( 'has prpl-widget-wrapper class', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'test-widget', + title: 'Test Widget', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.prpl-widget-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'has inner container', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'test-widget', + title: 'Test Widget', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + expect( + container.querySelector( '.widget-inner-container' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'force last column', () => { + it( 'sets data-force-last-column attribute to 1 when true', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'last-col-widget', + title: 'Last Column Widget', + component: MockWidget, + forceLastColumn: true, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( '.prpl-widget-wrapper' ); + expect( wrapper ).toHaveAttribute( 'data-force-last-column', '1' ); + } ); + + it( 'sets data-force-last-column attribute to 0 when false', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'normal-widget', + title: 'Normal Widget', + component: MockWidget, + forceLastColumn: false, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( '.prpl-widget-wrapper' ); + expect( wrapper ).toHaveAttribute( 'data-force-last-column', '0' ); + } ); + + it( 'defaults to 0 when not specified', () => { + const MockWidget = () =>
Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'default-widget', + title: 'Default Widget', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( '.prpl-widget-wrapper' ); + expect( wrapper ).toHaveAttribute( 'data-force-last-column', '0' ); + } ); + } ); + + describe( 'error boundary', () => { + it( 'wraps widgets in ErrorBoundary', () => { + const MockWidget = () =>
Widget Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'bounded-widget', + title: 'Bounded Widget', + component: MockWidget, + }, + ] ); + + render( ); + + expect( + screen.getByTestId( 'error-boundary-Bounded Widget' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'widget config passed to component', () => { + it( 'passes title in config', () => { + const MockWidget = ( { config } ) => ( +
{ config.title }
+ ); + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'config-widget', + title: 'Config Widget Title', + component: MockWidget, + }, + ] ); + + render( ); + + expect( screen.getByTestId( 'widget-title' ) ).toHaveTextContent( + 'Config Widget Title' + ); + } ); + } ); + + describe( 'special widget styles', () => { + it( 'applies special styles for todo widget', () => { + const MockWidget = () =>
Todo Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'todo', + title: 'Todo', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( '.prpl-todo' ); + expect( wrapper.style.paddingLeft ).toBe( '0px' ); + } ); + + it( 'applies flex styles for badge-streak widget', () => { + const MockWidget = () =>
Badge Streak Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'badge-streak', + title: 'Badge Streak', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( '.prpl-badge-streak' ); + expect( wrapper.style.display ).toBe( 'flex' ); + expect( wrapper.style.flexDirection ).toBe( 'column' ); + } ); + + it( 'applies flex styles for badge-streak-content widget', () => { + const MockWidget = () =>
Badge Streak Content
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'badge-streak-content', + title: 'Badge Streak Content', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( + '.prpl-badge-streak-content' + ); + expect( wrapper.style.display ).toBe( 'flex' ); + } ); + + it( 'applies flex styles for badge-streak-maintenance widget', () => { + const MockWidget = () =>
Badge Streak Maintenance
; + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'badge-streak-maintenance', + title: 'Badge Streak Maintenance', + component: MockWidget, + }, + ] ); + + const { container } = render( ); + + const wrapper = container.querySelector( + '.prpl-badge-streak-maintenance' + ); + expect( wrapper.style.display ).toBe( 'flex' ); + } ); + } ); + + describe( 'widget registration listener', () => { + it( 'updates widgets when registration action is triggered', async () => { + const MockWidget1 = () =>
Widget 1
; + const MockWidget2 = () =>
Widget 2
; + + // Initial state with one widget + getRegisteredWidgets.mockReturnValue( [ + { + id: 'widget-1', + title: 'Widget 1', + component: MockWidget1, + }, + ] ); + + render( ); + + expect( screen.getByText( 'Widget 1' ) ).toBeInTheDocument(); + + // Simulate widget registration by updating the mock + getRegisteredWidgets.mockReturnValue( [ + { + id: 'widget-1', + title: 'Widget 1', + component: MockWidget1, + }, + { + id: 'widget-2', + title: 'Widget 2', + component: MockWidget2, + }, + ] ); + + // Get the action callback and call it inside act() + const actionCallback = addAction.mock.calls[ 0 ][ 2 ]; + await act( async () => { + actionCallback(); + } ); + + expect( screen.getByText( 'Widget 2' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/Dashboard/__tests__/Welcome.test.js b/assets/src/components/Dashboard/__tests__/Welcome.test.js new file mode 100644 index 0000000000..1502818367 --- /dev/null +++ b/assets/src/components/Dashboard/__tests__/Welcome.test.js @@ -0,0 +1,572 @@ +/** + * Tests for Welcome Component + */ + +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import Welcome from '../Welcome'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, + sprintf: ( str, ...args ) => { + let result = str; + args.forEach( ( arg, i ) => { + result = result + .replace( '%s', arg ) + .replace( `%${ i + 1 }$s`, arg ); + } ); + return result; + }, +} ) ); + +describe( 'Welcome', () => { + const mockConfig = { + onboardNonceURL: 'https://api.example.com/nonce', + onboardAPIUrl: 'https://api.example.com/onboard', + ajaxUrl: '/wp-admin/admin-ajax.php', + nonce: 'test-nonce', + userFirstName: 'John', + userEmail: 'john@example.com', + siteUrl: 'https://mysite.com', + timezoneOffset: -5, + branding: { + progressIconHtml: '', + homeUrl: 'https://progressplanner.com', + privacyPolicyUrl: 'https://progressplanner.com/privacy-policy/', + }, + baseUrl: '/wp-content/plugins/progress-planner', + }; + + // Store original values + const originalFetch = global.fetch; + + beforeEach( () => { + jest.clearAllMocks(); + + // Mock fetch + global.fetch = jest.fn().mockResolvedValue( { + json: () => Promise.resolve( { status: 'ok' } ), + } ); + } ); + + afterEach( () => { + global.fetch = originalFetch; + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-welcome' ) + ).toBeInTheDocument(); + } ); + + it( 'renders welcome header', () => { + const { container } = render( ); + + expect( + container.querySelector( '.welcome-header' ) + ).toBeInTheDocument(); + } ); + + it( 'renders welcome title', () => { + render( ); + + expect( + screen.getByText( 'Welcome to the Progress Planner plugin!' ) + ).toBeInTheDocument(); + } ); + + it( 'renders progress icon when provided', () => { + const { container } = render( ); + + expect( + container.querySelector( '.progress-icon' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding form', () => { + const { container } = render( ); + + expect( + container.querySelector( '#prpl-onboarding-form' ) + ).toBeInTheDocument(); + } ); + + it( 'renders inner content section', () => { + const { container } = render( ); + + expect( + container.querySelector( '.inner-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding image', () => { + const { container } = render( ); + + const img = container.querySelector( 'img.onboarding' ); + expect( img ).toBeInTheDocument(); + expect( img.src ).toContain( 'image_onboarding_block.png' ); + } ); + } ); + + describe( 'email preference radio buttons', () => { + it( 'renders email preference radio buttons', () => { + const { container } = render( ); + + expect( + container.querySelector( '#prpl-with-email-yes' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '#prpl-with-email-no' ) + ).toBeInTheDocument(); + } ); + + it( 'has "yes" selected by default', () => { + const { container } = render( ); + + const yesRadio = container.querySelector( '#prpl-with-email-yes' ); + const noRadio = container.querySelector( '#prpl-with-email-no' ); + + expect( yesRadio.checked ).toBe( true ); + expect( noRadio.checked ).toBe( false ); + } ); + + it( 'can switch to "no" option', () => { + const { container } = render( ); + + const noRadio = container.querySelector( '#prpl-with-email-no' ); + fireEvent.click( noRadio ); + + expect( noRadio.checked ).toBe( true ); + } ); + + it( 'hides form fields when "no" is selected', () => { + const { container } = render( ); + + const noRadio = container.querySelector( '#prpl-with-email-no' ); + fireEvent.click( noRadio ); + + const formFields = container.querySelector( '.prpl-form-fields' ); + expect( formFields ).toHaveClass( 'prpl-hidden' ); + } ); + + it( 'shows form fields when "yes" is selected', () => { + const { container } = render( ); + + const formFields = container.querySelector( '.prpl-form-fields' ); + expect( formFields ).not.toHaveClass( 'prpl-hidden' ); + } ); + } ); + + describe( 'form fields', () => { + it( 'renders name input', () => { + const { container } = render( ); + + expect( + container.querySelector( '#prpl-name' ) + ).toBeInTheDocument(); + } ); + + it( 'renders email input', () => { + const { container } = render( ); + + expect( + container.querySelector( '#prpl-email' ) + ).toBeInTheDocument(); + } ); + + it( 'pre-fills name from config', () => { + const { container } = render( ); + + const nameInput = container.querySelector( '#prpl-name' ); + expect( nameInput.value ).toBe( 'John' ); + } ); + + it( 'pre-fills email from config', () => { + const { container } = render( ); + + const emailInput = container.querySelector( '#prpl-email' ); + expect( emailInput.value ).toBe( 'john@example.com' ); + } ); + + it( 'allows editing name', () => { + const { container } = render( ); + + const nameInput = container.querySelector( '#prpl-name' ); + fireEvent.change( nameInput, { target: { value: 'Jane' } } ); + + expect( nameInput.value ).toBe( 'Jane' ); + } ); + + it( 'allows editing email', () => { + const { container } = render( ); + + const emailInput = container.querySelector( '#prpl-email' ); + fireEvent.change( emailInput, { + target: { value: 'jane@example.com' }, + } ); + + expect( emailInput.value ).toBe( 'jane@example.com' ); + } ); + + it( 'name field is required when email preference is yes', () => { + const { container } = render( ); + + const nameInput = container.querySelector( '#prpl-name' ); + expect( nameInput ).toHaveAttribute( 'required' ); + } ); + + it( 'email field is required when email preference is yes', () => { + const { container } = render( ); + + const emailInput = container.querySelector( '#prpl-email' ); + expect( emailInput ).toHaveAttribute( 'required' ); + } ); + } ); + + describe( 'privacy policy checkbox', () => { + it( 'renders privacy policy checkbox', () => { + const { container } = render( ); + + expect( + container.querySelector( '#prpl-privacy-policy' ) + ).toBeInTheDocument(); + } ); + + it( 'privacy policy is unchecked by default', () => { + const { container } = render( ); + + const checkbox = container.querySelector( '#prpl-privacy-policy' ); + expect( checkbox.checked ).toBe( false ); + } ); + + it( 'can check privacy policy', () => { + const { container } = render( ); + + const checkbox = container.querySelector( '#prpl-privacy-policy' ); + fireEvent.click( checkbox ); + + expect( checkbox.checked ).toBe( true ); + } ); + + it( 'privacy policy checkbox is required', () => { + const { container } = render( ); + + const checkbox = container.querySelector( '#prpl-privacy-policy' ); + expect( checkbox ).toHaveAttribute( 'required' ); + } ); + } ); + + describe( 'submit buttons', () => { + it( 'renders submit button for email preference', () => { + render( ); + + expect( + screen.getByDisplayValue( + 'Get going and send me weekly emails' + ) + ).toBeInTheDocument(); + } ); + + it( 'renders submit button for no email preference', () => { + render( ); + + expect( + screen.getByDisplayValue( 'Continue without emailing me' ) + ).toBeInTheDocument(); + } ); + + it( 'shows email submit button when email preference is yes', () => { + const { container } = render( ); + + const emailSubmit = container.querySelector( + 'input[value="Get going and send me weekly emails"]' + ); + expect( emailSubmit ).not.toHaveClass( 'prpl-hidden' ); + } ); + + it( 'hides no-email submit button when email preference is yes', () => { + const { container } = render( ); + + const noEmailSubmit = container.querySelector( + 'input[value="Continue without emailing me"]' + ); + expect( noEmailSubmit ).toHaveClass( 'prpl-hidden' ); + } ); + + it( 'shows no-email submit button when email preference is no', () => { + const { container } = render( ); + + const noRadio = container.querySelector( '#prpl-with-email-no' ); + fireEvent.click( noRadio ); + + const noEmailSubmit = container.querySelector( + 'input[value="Continue without emailing me"]' + ); + expect( noEmailSubmit ).not.toHaveClass( 'prpl-hidden' ); + } ); + + it( 'submit wrapper is disabled when privacy policy not accepted', () => { + const { container } = render( ); + + const submitWrapper = container.querySelector( + '#prpl-onboarding-submit-wrapper' + ); + expect( submitWrapper ).toHaveClass( 'prpl-disabled' ); + } ); + + it( 'submit wrapper is enabled when privacy policy accepted', () => { + const { container } = render( ); + + const checkbox = container.querySelector( '#prpl-privacy-policy' ); + fireEvent.click( checkbox ); + + const submitWrapper = container.querySelector( + '#prpl-onboarding-submit-wrapper' + ); + expect( submitWrapper ).not.toHaveClass( 'prpl-disabled' ); + } ); + } ); + + describe( 'form submission', () => { + it( 'does not submit when privacy policy not accepted', async () => { + const { container } = render( ); + + const form = container.querySelector( '#prpl-onboarding-form' ); + + await act( async () => { + fireEvent.submit( form ); + } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + } ); + + it( 'submits form when privacy policy is accepted', async () => { + global.fetch = jest.fn().mockResolvedValue( { + json: () => + Promise.resolve( { status: 'ok', nonce: 'api-nonce' } ), + } ); + + const { container } = render( ); + + const checkbox = container.querySelector( '#prpl-privacy-policy' ); + fireEvent.click( checkbox ); + + const form = container.querySelector( '#prpl-onboarding-form' ); + + await act( async () => { + fireEvent.submit( form ); + } ); + + await waitFor( () => { + expect( global.fetch ).toHaveBeenCalled(); + } ); + } ); + } ); + + describe( 'branding', () => { + it( 'uses branding homeUrl for link', () => { + render( ); + + const links = screen.getAllByRole( 'link' ); + const homeLinks = links.filter( ( link ) => + link.href.includes( 'progressplanner.com' ) + ); + expect( homeLinks.length ).toBeGreaterThan( 0 ); + } ); + + it( 'uses branding privacyPolicyUrl for privacy link', () => { + render( ); + + const privacyLinks = screen + .getAllByRole( 'link' ) + .filter( ( link ) => + link.textContent.includes( 'Privacy policy' ) + ); + expect( privacyLinks.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'default config values', () => { + it( 'handles missing userFirstName', () => { + const configWithoutName = { + ...mockConfig, + userFirstName: undefined, + }; + + const { container } = render( + + ); + + const nameInput = container.querySelector( '#prpl-name' ); + expect( nameInput.value ).toBe( '' ); + } ); + + it( 'handles missing userEmail', () => { + const configWithoutEmail = { + ...mockConfig, + userEmail: undefined, + }; + + const { container } = render( + + ); + + const emailInput = container.querySelector( '#prpl-email' ); + expect( emailInput.value ).toBe( '' ); + } ); + + it( 'handles missing branding object', () => { + const configNoBranding = { + ...mockConfig, + branding: undefined, + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-welcome' ) + ).toBeInTheDocument(); + } ); + + it( 'handles missing progressIconHtml', () => { + const configNoIcon = { + ...mockConfig, + branding: { + ...mockConfig.branding, + progressIconHtml: null, + }, + }; + + const { container } = render( ); + + expect( + container.querySelector( '.progress-icon' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'hidden inputs', () => { + it( 'has hidden site input', () => { + const { container } = render( ); + + const siteInput = container.querySelector( 'input[name="site"]' ); + expect( siteInput ).toBeInTheDocument(); + expect( siteInput.type ).toBe( 'hidden' ); + expect( siteInput.value ).toBe( 'https://mysite.com' ); + } ); + + it( 'has hidden timezone_offset input', () => { + const { container } = render( ); + + const tzInput = container.querySelector( + 'input[name="timezone_offset"]' + ); + expect( tzInput ).toBeInTheDocument(); + expect( tzInput.type ).toBe( 'hidden' ); + expect( tzInput.value ).toBe( '-5' ); + } ); + } ); + + describe( 'form notice content', () => { + it( 'renders stay on track title', () => { + render( ); + + expect( + screen.getByText( 'Stay on track with weekly updates' ) + ).toBeInTheDocument(); + } ); + + it( 'renders choose your preference text', () => { + render( ); + + expect( + screen.getByText( 'Choose your preference:' ) + ).toBeInTheDocument(); + } ); + + it( 'renders yes email option text', () => { + render( ); + + expect( + screen.getByText( 'Yes, send me weekly updates!' ) + ).toBeInTheDocument(); + } ); + + it( 'renders no email option text', () => { + render( ); + + expect( + screen.getByText( 'No, I do not want emails right now.' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has prpl-form-notice class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-form-notice' ) + ).toBeInTheDocument(); + } ); + + it( 'has prpl-onboard-form-radio-select class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-onboard-form-radio-select' ) + ).toBeInTheDocument(); + } ); + + it( 'has prpl-form-fields class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-form-fields' ) + ).toBeInTheDocument(); + } ); + + it( 'submit button has prpl-button-primary class', () => { + const { container } = render( ); + + const submitBtn = container.querySelector( + 'input[value="Get going and send me weekly emails"]' + ); + expect( submitBtn ).toHaveClass( 'prpl-button-primary' ); + } ); + + it( 'no-email submit button has prpl-button-secondary class', () => { + const { container } = render( ); + + const submitBtn = container.querySelector( + 'input[value="Continue without emailing me"]' + ); + expect( submitBtn ).toHaveClass( 'prpl-button-secondary' ); + } ); + } ); + + describe( 'layout sections', () => { + it( 'has left section', () => { + const { container } = render( ); + + expect( container.querySelector( '.left' ) ).toBeInTheDocument(); + } ); + + it( 'has right section', () => { + const { container } = render( ); + + expect( container.querySelector( '.right' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/Dashboard/index.js b/assets/src/components/Dashboard/index.js new file mode 100644 index 0000000000..e1ebd16c99 --- /dev/null +++ b/assets/src/components/Dashboard/index.js @@ -0,0 +1,141 @@ +/** + * Dashboard Component + * + * Main dashboard component that conditionally renders Welcome/Onboarding + * or the main dashboard with header and widgets. + */ + +import { Fragment, useRef, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useDashboardStore } from '../../stores/dashboardStore'; +import DashboardHeader from './DashboardHeader'; +import DashboardWidgets from './DashboardWidgets'; +import OnboardingWizard from '../OnboardingWizard'; + +/** + * Style constants - extracted to prevent recreation on each render. + */ +const STYLES = { + skipLink: { + position: 'absolute', + top: '-40px', + left: 0, + background: 'var(--prpl-color-button-primary)', + color: 'var(--prpl-color-button-primary-text)', + padding: '8px 16px', + textDecoration: 'none', + borderRadius: 'var(--prpl-border-radius)', + zIndex: 100000, + }, + widgetsContainer: { + display: 'grid', + gridTemplateColumns: + 'repeat(auto-fit, minmax(var(--prpl-column-min-width), 1fr))', + columnGap: 'var(--prpl-gap)', + gridAutoRows: 'var(--prpl-gap)', + gridAutoFlow: 'dense', + }, +}; + +/** + * Dashboard component. + * + * @param {Object} props - Component props. + * @param {Object} props.config - Dashboard configuration from PHP. + * @return {JSX.Element} The Dashboard component. + */ +export default function Dashboard( { config } ) { + const { privacyPolicyAccepted = false } = config; + const wizardRef = useRef( null ); + const setShouldAutoStartWizard = useDashboardStore( + ( state ) => state.setShouldAutoStartWizard + ); + + // Set auto-start flag when privacy is not accepted (like develop branch) + // Note: Saved progress check is now handled by the wizard component after it fetches config from REST API + useEffect( () => { + // Auto-start if privacy not accepted (fresh install) + // Saved progress check is handled by wizard component after it fetches config from REST API + if ( ! privacyPolicyAccepted ) { + setShouldAutoStartWizard( true ); + } + }, [ privacyPolicyAccepted, setShouldAutoStartWizard ] ); + + /** + * Handle start onboarding button click. + */ + const handleStartOnboarding = () => { + if ( + wizardRef.current && + typeof wizardRef.current.startOnboarding === 'function' + ) { + wizardRef.current.startOnboarding(); + } + }; + + // Show start button when privacy not accepted (like develop branch) + if ( ! privacyPolicyAccepted ) { + return ( + +
+
+ +
+ +
+ +
+ ); + } + + // Show main dashboard (Zustand store provides cross-widget state) + return ( + + { + e.target.style.top = '10px'; + e.target.style.left = '10px'; + } } + onBlur={ ( e ) => { + e.target.style.top = '-40px'; + e.target.style.left = '0'; + } } + > + { __( 'Skip to main content', 'progress-planner' ) } + +

+ { __( 'Progress Planner', 'progress-planner' ) } +

+ +
+ +
+ +
+ ); +} diff --git a/assets/src/components/ErrorBoundary/__tests__/ErrorBoundary.test.js b/assets/src/components/ErrorBoundary/__tests__/ErrorBoundary.test.js new file mode 100644 index 0000000000..566d3b80a7 --- /dev/null +++ b/assets/src/components/ErrorBoundary/__tests__/ErrorBoundary.test.js @@ -0,0 +1,314 @@ +/** + * Tests for ErrorBoundary Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import ErrorBoundary from '../index'; + +// Suppress console.error during error boundary tests +const originalError = console.error; +beforeAll( () => { + console.error = jest.fn(); +} ); +afterAll( () => { + console.error = originalError; +} ); + +// Test component that throws an error +const ThrowingComponent = ( { shouldThrow = true } ) => { + if ( shouldThrow ) { + throw new Error( 'Test error' ); + } + return
Content rendered successfully
; +}; + +// Test component that can conditionally throw +const ConditionalThrowingComponent = ( { error } ) => { + if ( error ) { + throw error; + } + return
Child content
; +}; + +describe( 'ErrorBoundary', () => { + describe( 'normal rendering', () => { + it( 'renders children when no error occurs', () => { + render( + +
Test content
+
+ ); + + expect( screen.getByText( 'Test content' ) ).toBeInTheDocument(); + } ); + + it( 'renders multiple children', () => { + render( + +
First child
+
Second child
+
+ ); + + expect( screen.getByText( 'First child' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Second child' ) ).toBeInTheDocument(); + } ); + + it( 'passes through children unchanged', () => { + render( + + + + ); + + expect( + screen.getByText( 'Content rendered successfully' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'error handling', () => { + it( 'catches errors in child components', () => { + render( + + + + ); + + expect( + screen.getByText( 'Something went wrong' ) + ).toBeInTheDocument(); + } ); + + it( 'displays default error message', () => { + render( + + + + ); + + expect( + screen.getByText( + 'This widget failed to load. Try refreshing the page.' + ) + ).toBeInTheDocument(); + } ); + + it( 'displays retry button', () => { + render( + + + + ); + + expect( + screen.getByRole( 'button', { name: 'Retry' } ) + ).toBeInTheDocument(); + } ); + + it( 'logs error to console', () => { + render( + + + + ); + + expect( console.error ).toHaveBeenCalledWith( + 'ErrorBoundary caught an error:', + expect.any( Error ), + expect.objectContaining( { + componentStack: expect.any( String ), + } ) + ); + } ); + } ); + + describe( 'retry functionality', () => { + it( 'retry button resets error state', () => { + let shouldThrow = true; + + const DynamicThrow = () => { + if ( shouldThrow ) { + throw new Error( 'Test error' ); + } + return
Recovered content
; + }; + + render( + + + + ); + + // Verify error state + expect( + screen.getByText( 'Something went wrong' ) + ).toBeInTheDocument(); + + // Set to not throw and retry + shouldThrow = false; + fireEvent.click( screen.getByRole( 'button', { name: 'Retry' } ) ); + + // Should now show recovered content + expect( + screen.getByText( 'Recovered content' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'custom fallback', () => { + it( 'renders custom fallback when provided', () => { + const customFallback =
Custom error display
; + + render( + + + + ); + + expect( + screen.getByText( 'Custom error display' ) + ).toBeInTheDocument(); + expect( + screen.queryByText( 'Something went wrong' ) + ).not.toBeInTheDocument(); + } ); + + it( 'custom fallback can be a complex component', () => { + const customFallback = ( +
+

Error!

+

Something bad happened

+
+ ); + + render( + + + + ); + + expect( screen.getByText( 'Error!' ) ).toBeInTheDocument(); + expect( + screen.getByText( 'Something bad happened' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'onError callback', () => { + it( 'calls onError callback when error occurs', () => { + const onError = jest.fn(); + + render( + + + + ); + + expect( onError ).toHaveBeenCalledWith( + expect.any( Error ), + expect.objectContaining( { + componentStack: expect.any( String ), + } ) + ); + } ); + + it( 'onError receives the actual error object', () => { + const onError = jest.fn(); + const testError = new Error( 'Specific test error' ); + + render( + + + + ); + + expect( onError ).toHaveBeenCalledWith( + testError, + expect.any( Object ) + ); + } ); + + it( 'onError is not called when no error occurs', () => { + const onError = jest.fn(); + + render( + +
Normal content
+
+ ); + + expect( onError ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'styling', () => { + it( 'error container has error styling', () => { + const { container } = render( + + + + ); + + const errorContainer = container.firstChild; + expect( errorContainer ).toHaveStyle( { + border: '1px solid var(--prpl-color-error, #ef4444)', + } ); + } ); + + it( 'heading has error color', () => { + render( + + + + ); + + const heading = screen.getByRole( 'heading', { level: 4 } ); + expect( heading ).toHaveStyle( { + color: 'var(--prpl-color-error, #ef4444)', + } ); + } ); + } ); + + describe( 'isolation', () => { + it( 'one ErrorBoundary does not affect sibling', () => { + render( +
+ + + + +
Sibling content
+
+
+ ); + + // First boundary shows error + expect( + screen.getByText( 'Something went wrong' ) + ).toBeInTheDocument(); + // Second boundary renders normally + expect( screen.getByText( 'Sibling content' ) ).toBeInTheDocument(); + } ); + + it( 'nested ErrorBoundaries catch at correct level', () => { + render( + +
+

Outer content

+ + + +
+
+ ); + + // Inner error is caught + expect( + screen.getByText( 'Something went wrong' ) + ).toBeInTheDocument(); + // Outer content still renders + expect( screen.getByText( 'Outer content' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/ErrorBoundary/index.js b/assets/src/components/ErrorBoundary/index.js new file mode 100644 index 0000000000..45c025d261 --- /dev/null +++ b/assets/src/components/ErrorBoundary/index.js @@ -0,0 +1,143 @@ +/** + * ErrorBoundary Component + * + * Catches JavaScript errors in child component tree and displays a fallback UI. + * Prevents a single widget crash from breaking the entire dashboard. + * + * Note: Error boundaries must be class components as React doesn't provide + * hook equivalents for componentDidCatch and getDerivedStateFromError. + */ + +import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Style constants for error display. + */ +const STYLES = { + container: { + padding: 'var(--prpl-padding, 1rem)', + backgroundColor: 'var(--prpl-background-error, #fef2f2)', + borderRadius: 'var(--prpl-border-radius, 8px)', + border: '1px solid var(--prpl-color-error, #ef4444)', + }, + heading: { + margin: '0 0 0.5rem 0', + fontSize: 'var(--prpl-font-size-medium, 1rem)', + color: 'var(--prpl-color-error, #ef4444)', + }, + message: { + margin: 0, + fontSize: 'var(--prpl-font-size-small, 0.875rem)', + color: 'var(--prpl-color-text-secondary, #6b7280)', + }, + button: { + marginTop: '0.75rem', + padding: '0.5rem 1rem', + backgroundColor: 'var(--prpl-color-primary, #3b82f6)', + color: 'white', + border: 'none', + borderRadius: 'var(--prpl-border-radius-small, 4px)', + cursor: 'pointer', + fontSize: 'var(--prpl-font-size-small, 0.875rem)', + }, +}; + +/** + * ErrorBoundary class component. + */ +export default class ErrorBoundary extends Component { + /** + * Constructor. + * + * @param {Object} props - Component props. + */ + constructor( props ) { + super( props ); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + /** + * Update state when an error is caught. + * + * @param {Error} error - The error that was thrown. + * @return {Object} New state. + */ + static getDerivedStateFromError( error ) { + return { hasError: true, error }; + } + + /** + * Log error information for debugging. + * + * @param {Error} error - The error that was thrown. + * @param {Object} errorInfo - Component stack information. + */ + componentDidCatch( error, errorInfo ) { + // Log error for debugging + console.error( 'ErrorBoundary caught an error:', error, errorInfo ); + + this.setState( { errorInfo } ); + + // If an onError callback was provided, call it + if ( this.props.onError ) { + this.props.onError( error, errorInfo ); + } + } + + /** + * Reset error state to retry rendering. + */ + handleRetry = () => { + this.setState( { + hasError: false, + error: null, + errorInfo: null, + } ); + }; + + /** + * Render the component. + * + * @return {JSX.Element} The component. + */ + render() { + const { hasError } = this.state; + const { children, fallback } = this.props; + + if ( hasError ) { + // If a custom fallback was provided, use it + if ( fallback ) { + return fallback; + } + + // Default error UI + return ( +
+

+ { __( 'Something went wrong', 'progress-planner' ) } +

+

+ { __( + 'This widget failed to load. Try refreshing the page.', + 'progress-planner' + ) } +

+ +
+ ); + } + + return children; + } +} diff --git a/assets/src/components/FormInputs/CustomCheckbox.js b/assets/src/components/FormInputs/CustomCheckbox.js new file mode 100644 index 0000000000..0640a4098c --- /dev/null +++ b/assets/src/components/FormInputs/CustomCheckbox.js @@ -0,0 +1,48 @@ +/** + * CustomCheckbox Component + * + * A styled checkbox component matching the onboarding wizard design. + * + * @package + */ + +/** + * CustomCheckbox component. + * + * @param {Object} props - Component props. + * @param {string} props.id - Input ID. + * @param {string} props.name - Input name. + * @param {string} props.value - Input value. + * @param {boolean} props.checked - Whether checked. + * @param {Function} props.onChange - Change handler. + * @param {string|JSX.Element} props.label - Label text or JSX element. + * @param {string} props.className - Additional class names. + * @return {JSX.Element} CustomCheckbox component. + */ +export default function CustomCheckbox( { + id, + name, + value, + checked, + onChange, + label, + className = '', +} ) { + return ( + + ); +} diff --git a/assets/src/components/FormInputs/CustomRadio.js b/assets/src/components/FormInputs/CustomRadio.js new file mode 100644 index 0000000000..14de12343c --- /dev/null +++ b/assets/src/components/FormInputs/CustomRadio.js @@ -0,0 +1,48 @@ +/** + * CustomRadio Component + * + * A styled radio button component matching the onboarding wizard design. + * + * @package + */ + +/** + * CustomRadio component. + * + * @param {Object} props - Component props. + * @param {string} props.id - Input ID. + * @param {string} props.name - Input name (groups radios together). + * @param {string} props.value - Input value. + * @param {boolean} props.checked - Whether checked. + * @param {Function} props.onChange - Change handler. + * @param {string} props.label - Label text. + * @param {string} props.className - Additional class names. + * @return {JSX.Element} CustomRadio component. + */ +export default function CustomRadio( { + id, + name, + value, + checked, + onChange, + label, + className = '', +} ) { + return ( + + ); +} diff --git a/assets/src/components/FormInputs/FormInputs.css b/assets/src/components/FormInputs/FormInputs.css new file mode 100644 index 0000000000..6ef0aa0f38 --- /dev/null +++ b/assets/src/components/FormInputs/FormInputs.css @@ -0,0 +1,200 @@ +/** + * Custom Form Input Components Styles + * + * Styles for CustomCheckbox, CustomRadio, and ToggleSwitch components. + * These styles are self-contained so components can be reused anywhere. + */ + +/* Hide the default input */ +.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 styling */ +.prpl-custom-checkbox, +.prpl-custom-radio { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + cursor: pointer; + user-select: none; +} + +/* Checkbox styles */ +.prpl-custom-checkbox .prpl-custom-control { + border: 1px solid var(--prpl-color-selection-controls-inactive, #9ca3af); + background-color: var(--prpl-onboarding-popover-background, #fff); +} + +.prpl-custom-checkbox input[type="checkbox"]:checked + .prpl-custom-control { + background: var(--prpl-color-selection-controls, #534786); + border-color: var(--prpl-color-selection-controls, #534786); +} + +/* Checkmark */ +.prpl-custom-checkbox .prpl-custom-control::after { + content: ""; + position: absolute; + left: 6px; + top: 2px; + width: 4px; + height: 9px; + border: solid var(--prpl-onboarding-popover-background, #fff); + border-width: 0 2px 2px 0; + opacity: 0; + transform: scale(0.8) rotate(45deg); + transition: opacity 0.2s, transform 0.2s; +} + +.prpl-custom-checkbox 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, #9ca3af); + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background, #fff); +} + +.prpl-custom-radio input[type="radio"]:checked + .prpl-custom-control { + background: var(--prpl-color-selection-controls, #534786); + border-color: var(--prpl-color-selection-controls, #534786); +} + +/* Radio dot */ +.prpl-custom-radio .prpl-custom-control::after { + content: ""; + position: absolute; + top: 5px; + left: 5px; + width: 8px; + height: 8px; + background-color: var(--prpl-onboarding-popover-background, #fff); + border-radius: 50%; + opacity: 0; + transition: opacity 0.2s; +} + +.prpl-custom-radio input[type="radio"]:checked + .prpl-custom-control::after { + opacity: 1; +} + +/* Toggle Switch styles */ +.prpl-post-type-toggle-wrapper { + display: flex; + align-items: center; +} + +.prpl-post-type-toggle-label { + display: flex; + align-items: center; + gap: 0.75rem; + 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, #9ca3af); + transition: background-color 0.2s; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.prpl-post-type-toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background, #fff); + transition: transform 0.2s; + z-index: 1; +} + +.prpl-post-type-toggle-switch 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, #6b7280); +} + +.prpl-post-type-toggle-switch .prpl-toggle-icon-check { + display: none; +} + +.prpl-post-type-toggle-switch .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, #534786); +} + +.prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch::after { + transform: translateX(20px); +} + +.prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch svg { + left: 26px; + color: var(--prpl-background-step-active, #534786); + transform: translateY(-50%); +} + +.prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch .prpl-toggle-icon-check { + display: block; +} + +.prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch .prpl-toggle-icon-x { + display: none; +} + +.prpl-post-type-toggle-text { + font-size: 16px; + line-height: 1.5; + transition: opacity 0.2s; +} diff --git a/assets/src/components/FormInputs/ToggleSwitch.js b/assets/src/components/FormInputs/ToggleSwitch.js new file mode 100644 index 0000000000..5aaba59a55 --- /dev/null +++ b/assets/src/components/FormInputs/ToggleSwitch.js @@ -0,0 +1,56 @@ +/** + * ToggleSwitch Component + * + * A styled toggle switch component matching the onboarding wizard design. + * Used for post type selection and similar on/off toggles. + * + * @package + */ + +import Icon from '../Icon'; + +/** + * ToggleSwitch component. + * + * @param {Object} props - Component props. + * @param {string} props.id - Input ID. + * @param {string} props.name - Input name. + * @param {string} props.value - Input value. + * @param {boolean} props.checked - Whether checked. + * @param {Function} props.onChange - Change handler. + * @param {string} props.label - Label text. + * @param {string} props.className - Additional class names. + * @return {JSX.Element} ToggleSwitch component. + */ +export default function ToggleSwitch( { + id, + name, + value, + checked, + onChange, + label, + className = '', +} ) { + return ( +
+ +
+ ); +} diff --git a/assets/src/components/FormInputs/index.js b/assets/src/components/FormInputs/index.js new file mode 100644 index 0000000000..c3d0bbcb30 --- /dev/null +++ b/assets/src/components/FormInputs/index.js @@ -0,0 +1,13 @@ +/** + * Form Input Components + * + * Reusable styled form input components. + * + * @package + */ + +import './FormInputs.css'; + +export { default as CustomCheckbox } from './CustomCheckbox'; +export { default as CustomRadio } from './CustomRadio'; +export { default as ToggleSwitch } from './ToggleSwitch'; diff --git a/assets/src/components/Gauge/GaugeSkeleton.js b/assets/src/components/Gauge/GaugeSkeleton.js new file mode 100644 index 0000000000..4e5191a7b2 --- /dev/null +++ b/assets/src/components/Gauge/GaugeSkeleton.js @@ -0,0 +1,73 @@ +/** + * Gauge Skeleton Component + * + * Skeleton loading state for the Gauge component. + */ + +import { SkeletonCircle, SkeletonRect } from '../Skeleton'; + +/** + * GaugeSkeleton component. + * + * @param {Object} props - Component props. + * @param {string} props.backgroundColor - Background color CSS variable. + * @return {JSX.Element} The GaugeSkeleton component. + */ +export default function GaugeSkeleton( { + backgroundColor = 'var(--prpl-background-monthly)', +} ) { + const containerStyle = { + padding: + 'var(--prpl-padding) var(--prpl-padding) calc(var(--prpl-padding) * 2) var(--prpl-padding)', + background: backgroundColor, + borderRadius: 'var(--prpl-border-radius-big)', + aspectRatio: '2 / 1', + overflow: 'hidden', + position: 'relative', + marginBottom: 'var(--prpl-padding)', + display: 'flex', + justifyContent: 'center', + alignItems: 'flex-start', + }; + + const gaugeWrapperStyle = { + width: '100%', + aspectRatio: '1 / 1', + position: 'relative', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }; + + const centerContentStyle = { + position: 'absolute', + top: '35%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.5em', + }; + + return ( +
+
+ { /* Outer ring skeleton */ } + + { /* Center content placeholder */ } +
+ +
+
+
+ ); +} diff --git a/assets/src/components/Gauge/__tests__/Gauge.test.js b/assets/src/components/Gauge/__tests__/Gauge.test.js new file mode 100644 index 0000000000..22093c3520 --- /dev/null +++ b/assets/src/components/Gauge/__tests__/Gauge.test.js @@ -0,0 +1,359 @@ +/** + * Tests for Gauge Component + */ + +import { render, screen } from '@testing-library/react'; +import Gauge from '../index'; + +describe( 'Gauge', () => { + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'renders gauge ring', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'renders min label as 0', () => { + render( ); + + expect( screen.getByText( '0' ) ).toBeInTheDocument(); + } ); + + it( 'renders max label with default value', () => { + render( ); + + expect( screen.getByText( '10' ) ).toBeInTheDocument(); + } ); + + it( 'renders max label with custom value', () => { + render( ); + + expect( screen.getByText( '100' ) ).toBeInTheDocument(); + } ); + + it( 'renders children content', () => { + render( + + 5 pts + + ); + + expect( screen.getByText( '5 pts' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'styling', () => { + it( 'applies default background color', () => { + const { container } = render( ); + + const gauge = container.querySelector( '.prpl-gauge' ); + expect( gauge ).toHaveStyle( { + background: 'var(--prpl-background-monthly)', + } ); + } ); + + it( 'applies custom background color', () => { + const { container } = render( + + ); + + const gauge = container.querySelector( '.prpl-gauge' ); + expect( gauge ).toHaveStyle( { + background: 'var(--custom-bg)', + } ); + } ); + + it( 'applies aspect ratio to container', () => { + const { container } = render( ); + + const gauge = container.querySelector( '.prpl-gauge' ); + // jsdom doesn't fully support aspectRatio, check style attribute + expect( gauge.style.aspectRatio ).toBe( '2 / 1' ); + } ); + + it( 'applies default content font size', () => { + const { container } = render( ); + + const content = container.querySelector( '.prpl-gauge__content' ); + expect( content ).toHaveStyle( { + fontSize: 'var(--prpl-font-size-6xl)', + } ); + } ); + + it( 'applies custom content font size', () => { + const { container } = render( ); + + const content = container.querySelector( '.prpl-gauge__content' ); + expect( content ).toHaveStyle( { + fontSize: '2rem', + } ); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has main gauge class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'has ring class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'has min label class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__label--min' ) + ).toBeInTheDocument(); + } ); + + it( 'has max label class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__label--max' ) + ).toBeInTheDocument(); + } ); + + it( 'has content class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__content' ) + ).toBeInTheDocument(); + } ); + + it( 'has content inner class', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__content-inner' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'progress calculation', () => { + it( 'handles 0% progress', () => { + const { container } = render( ); + + // Component should render with gauge ring + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'handles 50% progress', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'handles 100% progress', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'handles max of 0 gracefully', () => { + const { container } = render( ); + + // Should not crash and should render + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'handles value exceeding max', () => { + const { container } = render( ); + + // Should not crash and should render + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'label positioning', () => { + it( 'positions min label on the left', () => { + const { container } = render( ); + + const minLabel = container.querySelector( + '.prpl-gauge__label--min' + ); + expect( minLabel ).toHaveStyle( { + left: '0', + } ); + } ); + + it( 'positions max label on the right', () => { + const { container } = render( ); + + const maxLabel = container.querySelector( + '.prpl-gauge__label--max' + ); + expect( maxLabel ).toHaveStyle( { + right: '0', + } ); + } ); + + it( 'labels are at 50% top position', () => { + const { container } = render( ); + + const minLabel = container.querySelector( + '.prpl-gauge__label--min' + ); + const maxLabel = container.querySelector( + '.prpl-gauge__label--max' + ); + + expect( minLabel ).toHaveStyle( { top: '50%' } ); + expect( maxLabel ).toHaveStyle( { top: '50%' } ); + } ); + } ); + + describe( 'color props', () => { + it( 'renders with default colors', () => { + const { container } = render( ); + + // Component should render with default colors (can't test CSS gradient in jsdom) + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'renders with custom color prop', () => { + const { container } = render( + + ); + + // Component should accept custom color + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'renders with color2 prop for high progress', () => { + const { container } = render( + + ); + + // Component should accept color2 prop + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + + it( 'renders for progress under 50%', () => { + const { container } = render( + + ); + + // Component should render correctly + expect( + container.querySelector( '.prpl-gauge__ring' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'children rendering', () => { + it( 'renders text children', () => { + render( 50% ); + + expect( screen.getByText( '50%' ) ).toBeInTheDocument(); + } ); + + it( 'renders element children', () => { + render( + + Bold text + + ); + + expect( screen.getByTestId( 'child' ) ).toBeInTheDocument(); + } ); + + it( 'renders multiple children', () => { + render( + + Line 1 + Line 2 + + ); + + expect( screen.getByText( 'Line 1' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Line 2' ) ).toBeInTheDocument(); + } ); + + it( 'renders without children', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge__content-inner' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles negative value', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'handles negative max', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'handles decimal values', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'handles large values', () => { + render( ); + + expect( screen.getByText( '1000000' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/Gauge/index.js b/assets/src/components/Gauge/index.js new file mode 100644 index 0000000000..172320cdf7 --- /dev/null +++ b/assets/src/components/Gauge/index.js @@ -0,0 +1,139 @@ +/** + * Gauge Component + * + * Displays a semi-circular progress gauge using CSS conic-gradient. + */ + +import { useMemo } from '@wordpress/element'; + +/** + * Gauge component. + * + * @param {Object} props - Component props. + * @param {number} props.value - Current progress value. + * @param {number} props.max - Maximum value (default 10). + * @param {string} props.backgroundColor - Background color CSS variable. + * @param {string} props.color - Primary progress color CSS variable. + * @param {string} props.color2 - Secondary progress color CSS variable. + * @param {string} props.contentFontSize - Font size for the content inside the gauge. + * @param {JSX.Element} props.children - Content to display in the gauge center. + * @return {JSX.Element} The Gauge component. + */ +export default function Gauge( { + value = 0, + max = 10, + backgroundColor = 'var(--prpl-background-monthly)', + color = 'var(--prpl-color-monthly)', + color2 = 'var(--prpl-color-monthly-2)', + contentFontSize = 'var(--prpl-font-size-6xl)', + children, +} ) { + const maxDeg = '180deg'; + const start = '270deg'; + const cutout = '57%'; + + /** + * Calculate the conic gradient color transitions. + */ + const colorTransitions = useMemo( () => { + const progress = max > 0 ? value / max : 0; + let transitions; + + // If progress is less than 50%, use single color (no gradient) + if ( progress <= 0.5 ) { + transitions = `${ color } calc(${ maxDeg } * ${ progress })`; + } else { + // Show first color for 0.5, then second color + transitions = `${ color } calc(${ maxDeg } * 0.5)`; + transitions += `, ${ color2 } calc(${ maxDeg } * ${ progress })`; + } + + // Add remaining (unfilled) color + transitions += `, var(--prpl-color-gauge-remain) calc(${ maxDeg } * ${ progress }) ${ maxDeg }`; + + return transitions; + }, [ value, max, color, color2 ] ); + + const containerStyle = { + padding: + 'var(--prpl-padding) var(--prpl-padding) calc(var(--prpl-padding) * 2) var(--prpl-padding)', + background: backgroundColor, + borderRadius: 'var(--prpl-border-radius-big)', + aspectRatio: '2 / 1', + overflow: 'hidden', + position: 'relative', + marginBottom: 'var(--prpl-padding)', + }; + + const gaugeStyle = { + width: '100%', + aspectRatio: '1 / 1', + borderRadius: '100%', + position: 'relative', + background: `radial-gradient(${ backgroundColor } 0 ${ cutout }, transparent ${ cutout } 100%), conic-gradient(from ${ start }, ${ colorTransitions }, transparent ${ maxDeg })`, + textAlign: 'center', + }; + + const labelStyle = { + fontSize: 'var(--prpl-font-size-small)', + position: 'absolute', + top: '50%', + color: 'var(--prpl-color-text)', + width: '10%', + textAlign: 'center', + }; + + const leftLabelStyle = { + ...labelStyle, + left: 0, + }; + + const rightLabelStyle = { + ...labelStyle, + right: 0, + }; + + const contentStyle = { + fontSize: contentFontSize, + bottom: '50%', + display: 'block', + fontWeight: 600, + textAlign: 'center', + position: 'absolute', + color: 'var(--prpl-color-text)', + width: '100%', + lineHeight: 1.2, + }; + + const contentInnerStyle = { + display: 'inline-block', + width: '50%', + }; + + return ( +
+
+ + 0 + + + + { children } + + + + { max } + +
+
+ ); +} diff --git a/assets/src/components/Icon/__tests__/Icon.test.js b/assets/src/components/Icon/__tests__/Icon.test.js new file mode 100644 index 0000000000..927c30de28 --- /dev/null +++ b/assets/src/components/Icon/__tests__/Icon.test.js @@ -0,0 +1,66 @@ +/** + * Tests for Icon Component + */ + +import { render } from '@testing-library/react'; + +import Icon from '../index'; + +describe( 'Icon', () => { + const iconNames = [ + 'arrow', + 'trash', + 'check', + 'close', + 'chevronDown', + 'warning', + ]; + + describe( 'renders each named icon', () => { + it.each( iconNames )( 'renders "%s" as an svg', ( name ) => { + const { container } = render( ); + expect( container.querySelector( 'svg' ) ).toBeInTheDocument(); + } ); + } ); + + it( 'returns null for unknown names', () => { + const { container } = render( ); + expect( container.innerHTML ).toBe( '' ); + } ); + + it( 'sets default aria-hidden and focusable attributes', () => { + const { container } = render( ); + const svg = container.querySelector( 'svg' ); + expect( svg ).toHaveAttribute( 'aria-hidden', 'true' ); + expect( svg ).toHaveAttribute( 'focusable', 'false' ); + } ); + + it( 'passes className prop through', () => { + const { container } = render( + + ); + const svg = container.querySelector( 'svg' ); + expect( svg ).toHaveAttribute( 'class', 'my-class' ); + } ); + + it( 'passes style prop through', () => { + const { container } = render( + + ); + const svg = container.querySelector( 'svg' ); + expect( svg.style.width ).toBe( '2rem' ); + } ); + + it( 'uses stroke for chevronDown icon', () => { + const { container } = render( ); + const svg = container.querySelector( 'svg' ); + expect( svg ).toHaveAttribute( 'stroke', 'currentColor' ); + expect( svg ).toHaveAttribute( 'fill', 'none' ); + } ); + + it( 'uses fill for non-stroke icons', () => { + const { container } = render( ); + const svg = container.querySelector( 'svg' ); + expect( svg ).toHaveAttribute( 'fill', 'currentColor' ); + } ); +} ); diff --git a/assets/src/components/Icon/index.js b/assets/src/components/Icon/index.js new file mode 100644 index 0000000000..c56a5a448b --- /dev/null +++ b/assets/src/components/Icon/index.js @@ -0,0 +1,101 @@ +/** + * Reusable Icon component with SVG path registry. + * + * @param {Object} props - Component props. + * @param {string} props.name - Icon name from the registry. + * @return {JSX.Element|null} The SVG icon or null for unknown names. + */ + +const ICONS = { + arrow: { + viewBox: '0 0 20 17', + paths: [ + { + d: 'M19.92 8.12c-.05-.12-.12-.23-.22-.33L12.21.29A.996.996 0 1 0 10.8 1.7l5.79 5.79H1c-.55 0-1 .45-1 1s.45 1 1 1h15.59l-5.79 5.79a.996.996 0 0 0 .71 1.7c.26 0 .51-.1.71-.29l7.5-7.5c.1-.1.17-.21.22-.33.05-.12.07-.24.08-.38 0-.14-.03-.27-.08-.38Z', + }, + ], + }, + trash: { + viewBox: '0 0 48 48', + paths: [ + { + d: 'M32.99 47.88H15.01c-3.46 0-6.38-2.7-6.64-6.15L6.04 11.49l-.72.12c-.82.14-1.59-.41-1.73-1.22-.14-.82.41-1.59 1.22-1.73.79-.14 1.57-.26 2.37-.38h.02c2.21-.33 4.46-.6 6.69-.81v-.72c0-3.56 2.74-6.44 6.25-6.55 2.56-.08 5.15-.08 7.71 0 3.5.11 6.25 2.99 6.25 6.55v.72c2.24.2 4.48.47 6.7.81.79.12 1.59.25 2.38.39.82.14 1.36.92 1.22 1.73-.14.82-.92 1.36-1.73 1.22l-.72-.12-2.33 30.24c-.27 3.45-3.18 6.15-6.64 6.15Zm-17.98-3h17.97c1.9 0 3.51-1.48 3.65-3.38l2.34-30.46c-2.15-.3-4.33-.53-6.48-.7h-.03c-5.62-.43-11.32-.43-16.95 0h-.03c-2.15.17-4.33.4-6.48.7l2.34 30.46c.15 1.9 1.75 3.38 3.65 3.38ZM24 7.01c2.37 0 4.74.07 7.11.22v-.49c0-1.93-1.47-3.49-3.34-3.55-2.5-.08-5.03-.08-7.52 0-1.88.06-3.34 1.62-3.34 3.55v.49c2.36-.15 4.73-.22 7.11-.22Zm5.49 32.26h-.06c-.83-.03-1.47-.73-1.44-1.56l.79-20.65c.03-.83.75-1.45 1.56-1.44.83.03 1.47.73 1.44 1.56l-.79 20.65c-.03.81-.7 1.44-1.5 1.44Zm-10.98 0c-.8 0-1.47-.63-1.5-1.44l-.79-20.65c-.03-.83.61-1.52 1.44-1.56.84 0 1.52.61 1.56 1.44l.79 20.65c.03.83-.61 1.52-1.44 1.56h-.06Z', + }, + ], + }, + check: { + viewBox: '0 0 20 20', + paths: [ + { + d: 'M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z', + fillRule: 'evenodd', + clipRule: 'evenodd', + }, + ], + }, + close: { + viewBox: '0 0 20 20', + paths: [ + { + d: 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z', + fillRule: 'evenodd', + clipRule: 'evenodd', + }, + ], + }, + chevronDown: { + viewBox: '0 0 24 24', + stroke: true, + paths: [ + { + d: 'm19.5 8.25-7.5 7.5-7.5-7.5', + strokeLinecap: 'round', + strokeLinejoin: 'round', + }, + ], + }, + info: { + viewBox: '0 0 64 64', + paths: [ + { + d: 'M32 63.73C14.51 63.73.27 49.49.27 32S14.51.27 32 .27 63.73 14.5 63.73 32 49.5 63.73 32 63.73Zm0-58C17.51 5.73 5.73 17.52 5.73 32S17.52 58.27 32 58.27 58.27 46.48 58.27 32 46.49 5.73 32 5.73Zm1.2 41.4c-.42 0-.83-.05-1.24-.15-1.33-.33-2.46-1.17-3.16-2.34-.71-1.18-.91-2.56-.58-3.9l2.13-8.54c-1.25.36-2.62-.21-3.21-1.42-.66-1.35-.1-2.99 1.25-3.65l.13-.06c1.21-.6 2.6-.7 3.91-.27 1.3.44 2.36 1.35 2.97 2.58.55 1.1.69 2.36.39 3.54l-2.13 8.54c1.22-.36 2.58.19 3.19 1.37.69 1.34.16 2.98-1.18 3.67l-.13.07c-.74.37-1.54.56-2.34.56Zm-2.26-15.17Zm1.09-9.03c-1.65 0-3.02-1.34-3.02-2.99s1.34-3.01 2.99-3.01h.03c1.65 0 2.99 1.34 2.99 2.99s-1.34 3.01-2.99 3.01Z', + }, + ], + }, + warning: { + viewBox: '0 0 24 24', + paths: [ + { + d: 'M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z', + fillRule: 'evenodd', + clipRule: 'evenodd', + }, + ], + }, +}; + +export default function Icon( { name, ...props } ) { + const icon = ICONS[ name ]; + if ( ! icon ) { + return null; + } + + const isStroke = icon.stroke; + + return ( + + ); +} diff --git a/assets/src/components/InstallPluginButton.css b/assets/src/components/InstallPluginButton.css new file mode 100644 index 0000000000..2369799e3e --- /dev/null +++ b/assets/src/components/InstallPluginButton.css @@ -0,0 +1,44 @@ +.prpl-button-link { + text-decoration: underline; + color: var(--prpl-color-link); + background: none; + border: none; + padding: 0; + margin: 0; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + text-align: inherit; + cursor: pointer; + + display: flex !important; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.prpl-install-button-loader { + display: none; + width: 1rem; + height: 1rem; + border: 3px solid var(--prpl-color-link); + border-bottom-color: transparent; + border-radius: 50%; + box-sizing: border-box; + animation: install-button-rotation 1s linear infinite; +} + +button:disabled .prpl-install-button-loader { + display: block; +} + +@keyframes install-button-rotation { + + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/assets/src/components/InstallPluginButton.js b/assets/src/components/InstallPluginButton.js new file mode 100644 index 0000000000..13d9d8d7ed --- /dev/null +++ b/assets/src/components/InstallPluginButton.js @@ -0,0 +1,156 @@ +/** + * Install Plugin Button Component. + * + * Replaces the prpl-install-plugin web component. + * Handles plugin installation and activation. + * + * @param {Object} props Component props. + * @param {string} props.pluginSlug The plugin slug. + * @param {string} props.pluginName The plugin name. + * @param {string} props.action The action: 'install' or 'activate'. + * @param {boolean} props.completeTask Whether to complete the task after activation. + * @param {string} props.providerId The provider ID for task completion. + * @param {string} props.className CSS class name for the button. + * @return {JSX.Element} The install plugin button component. + */ + +import { useState, useCallback } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import './InstallPluginButton.css'; + +export default function InstallPluginButton( { + pluginSlug, + pluginName, + action = 'install', + completeTask = true, + providerId, + className = 'prpl-button-link', +} ) { + const [ currentAction, setCurrentAction ] = useState( action ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ status, setStatus ] = useState( 'idle' ); // idle, installing, activating, activated + + /** + * Install plugin. + */ + const installPlugin = useCallback( async () => { + setIsLoading( true ); + setStatus( 'installing' ); + + try { + await apiFetch( { + path: '/progress-planner/v1/plugins/install', + method: 'POST', + data: { + plugin_slug: pluginSlug, + }, + } ); + + // After installation, activate the plugin + await activatePlugin(); + } catch ( err ) { + console.error( 'Error installing plugin:', err ); // eslint-disable-line no-console + setStatus( 'idle' ); + setIsLoading( false ); + } + }, [ pluginSlug, activatePlugin ] ); + + /** + * Activate plugin. + */ + const activatePlugin = useCallback( async () => { + setStatus( 'activating' ); + + try { + await apiFetch( { + path: '/progress-planner/v1/plugins/activate', + method: 'POST', + data: { + plugin_slug: pluginSlug, + }, + } ); + + setStatus( 'activated' ); + setCurrentAction( 'activated' ); + + // Complete task if needed + if ( completeTask && providerId ) { + // Trigger task completion via hook + // This will be handled by the parent component or PopoverManager + if ( window.prplSuggestedTask?.maybeComplete ) { + // Find the task element and complete it + const taskElement = document.querySelector( + `#prpl-suggested-tasks-list .prpl-suggested-task[data-task-id="${ providerId }"]` + ); + if ( taskElement ) { + const postId = parseInt( taskElement.dataset.postId ); + if ( postId ) { + window.prplSuggestedTask.maybeComplete( postId ); + } + } + } + } + } catch ( err ) { + console.error( 'Error activating plugin:', err ); // eslint-disable-line no-console + setStatus( 'idle' ); + } finally { + setIsLoading( false ); + } + }, [ pluginSlug, completeTask, providerId ] ); + + /** + * Handle button click. + */ + const handleClick = useCallback( () => { + if ( currentAction === 'install' ) { + installPlugin(); + } else if ( currentAction === 'activate' ) { + activatePlugin(); + } + }, [ currentAction, installPlugin, activatePlugin ] ); + + // Get button text based on status + const getButtonText = () => { + if ( status === 'activated' ) { + return __( 'Activated', 'progress-planner' ); + } + if ( status === 'activating' ) { + return __( 'Activating…', 'progress-planner' ); + } + if ( status === 'installing' ) { + return __( 'Installing…', 'progress-planner' ); + } + if ( currentAction === 'install' ) { + return sprintf( + // translators: %s is the plugin name. + __( 'Install %s', 'progress-planner' ), + pluginName + ); + } + return sprintf( + // translators: %s is the plugin name. + __( 'Activate %s', 'progress-planner' ), + pluginName + ); + }; + + return ( + + ); +} diff --git a/assets/src/components/LineChart/ChartFilters.js b/assets/src/components/LineChart/ChartFilters.js new file mode 100644 index 0000000000..18898d4526 --- /dev/null +++ b/assets/src/components/LineChart/ChartFilters.js @@ -0,0 +1,86 @@ +/** + * ChartFilters Component + * + * Displays filter checkboxes for toggling chart series visibility. + */ + +/** + * ChartFilters component. + * + * @param {Object} props - Component props. + * @param {Object} props.dataArgs - Data arguments with color and label per series. + * @param {string[]} props.visibleSeries - Array of visible series keys. + * @param {string} props.filtersLabel - Optional label to show before filters. + * @param {Function} props.onToggle - Callback when a series is toggled. + * @return {JSX.Element} The ChartFilters component. + */ +export default function ChartFilters( { + dataArgs, + visibleSeries, + filtersLabel, + onToggle, +} ) { + const containerStyle = { + display: 'flex', + gap: '1em', + marginBottom: '1em', + justifyContent: 'space-between', + fontSize: '0.85rem', + }; + + const labelStyle = { + display: 'flex', + alignItems: 'center', + gap: '0.25em', + cursor: 'pointer', + }; + + const getCheckboxColorStyle = ( key ) => ( { + backgroundColor: visibleSeries.includes( key ) + ? dataArgs[ key ].color + : 'transparent', + width: '1em', + height: '1em', + borderRadius: '0.25em', + outline: `1px solid ${ dataArgs[ key ].color }`, + border: '1px solid #fff', + } ); + + const hiddenInputStyle = { + display: 'none', + }; + + return ( +
+ { filtersLabel && ( + + ) } + { Object.keys( dataArgs ).map( ( key ) => ( + + ) ) } +
+ ); +} diff --git a/assets/src/components/LineChart/__tests__/ChartFilters.test.js b/assets/src/components/LineChart/__tests__/ChartFilters.test.js new file mode 100644 index 0000000000..5fa8660fe9 --- /dev/null +++ b/assets/src/components/LineChart/__tests__/ChartFilters.test.js @@ -0,0 +1,431 @@ +/** + * Tests for ChartFilters Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import ChartFilters from '../ChartFilters'; + +describe( 'ChartFilters', () => { + const mockDataArgs = { + series1: { color: '#ff0000', label: 'Series 1' }, + series2: { color: '#00ff00', label: 'Series 2' }, + series3: { color: '#0000ff', label: 'Series 3' }, + }; + + const mockOnToggle = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filters' ) + ).toBeInTheDocument(); + } ); + + it( 'renders all filter labels', () => { + render( + + ); + + expect( screen.getByText( 'Series 1' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Series 2' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Series 3' ) ).toBeInTheDocument(); + } ); + + it( 'renders checkboxes for each series', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-chart-filter-series1' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '#prpl-chart-filter-series2' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '#prpl-chart-filter-series3' ) + ).toBeInTheDocument(); + } ); + + it( 'renders filter classes with series key', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filter--series1' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '.prpl-line-chart__filter--series2' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'checkbox states', () => { + it( 'sets checkboxes as checked for visible series', () => { + const { container } = render( + + ); + + const checkbox1 = container.querySelector( + '#prpl-chart-filter-series1' + ); + const checkbox2 = container.querySelector( + '#prpl-chart-filter-series2' + ); + const checkbox3 = container.querySelector( + '#prpl-chart-filter-series3' + ); + + expect( checkbox1 ).toBeChecked(); + expect( checkbox2 ).toBeChecked(); + expect( checkbox3 ).not.toBeChecked(); + } ); + + it( 'sets all checkboxes unchecked when no series visible', () => { + const { container } = render( + + ); + + const checkbox1 = container.querySelector( + '#prpl-chart-filter-series1' + ); + const checkbox2 = container.querySelector( + '#prpl-chart-filter-series2' + ); + const checkbox3 = container.querySelector( + '#prpl-chart-filter-series3' + ); + + expect( checkbox1 ).not.toBeChecked(); + expect( checkbox2 ).not.toBeChecked(); + expect( checkbox3 ).not.toBeChecked(); + } ); + } ); + + describe( 'toggle behavior', () => { + it( 'calls onToggle with series key when checkbox clicked', () => { + const { container } = render( + + ); + + const checkbox = container.querySelector( + '#prpl-chart-filter-series1' + ); + fireEvent.click( checkbox ); + + expect( mockOnToggle ).toHaveBeenCalledWith( 'series1' ); + } ); + + it( 'calls onToggle for each unique series', () => { + const { container } = render( + + ); + + fireEvent.click( + container.querySelector( '#prpl-chart-filter-series1' ) + ); + fireEvent.click( + container.querySelector( '#prpl-chart-filter-series2' ) + ); + fireEvent.click( + container.querySelector( '#prpl-chart-filter-series3' ) + ); + + expect( mockOnToggle ).toHaveBeenCalledTimes( 3 ); + expect( mockOnToggle ).toHaveBeenCalledWith( 'series1' ); + expect( mockOnToggle ).toHaveBeenCalledWith( 'series2' ); + expect( mockOnToggle ).toHaveBeenCalledWith( 'series3' ); + } ); + } ); + + describe( 'filters label', () => { + it( 'does not render label span when filtersLabel empty', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filters-label' ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders label span when filtersLabel provided', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filters-label' ) + ).toBeInTheDocument(); + expect( screen.getByText( 'Filter by:' ) ).toBeInTheDocument(); + } ); + + it( 'renders HTML content in filtersLabel', () => { + const { container } = render( + + ); + + const label = container.querySelector( + '.prpl-line-chart__filters-label' + ); + expect( label.querySelector( 'strong' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'styling', () => { + it( 'applies flex container style', () => { + const { container } = render( + + ); + + const filters = container.querySelector( + '.prpl-line-chart__filters' + ); + expect( filters ).toHaveStyle( { + display: 'flex', + gap: '1em', + marginBottom: '1em', + justifyContent: 'space-between', + } ); + } ); + + it( 'applies label style with cursor pointer', () => { + const { container } = render( + + ); + + const label = container.querySelector( '.prpl-line-chart__filter' ); + expect( label ).toHaveStyle( { + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + } ); + } ); + + it( 'hides checkbox input visually', () => { + const { container } = render( + + ); + + const checkbox = container.querySelector( + '#prpl-chart-filter-series1' + ); + expect( checkbox ).toHaveStyle( { display: 'none' } ); + } ); + } ); + + describe( 'color indicator', () => { + it( 'renders color indicator span', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filter-color' ) + ).toBeInTheDocument(); + } ); + + it( 'shows filled color when series visible', () => { + const { container } = render( + + ); + + const colorIndicators = container.querySelectorAll( + '.prpl-line-chart__filter-color' + ); + // series1 is visible - background should be filled + expect( colorIndicators[ 0 ] ).toHaveStyle( { + backgroundColor: '#ff0000', + } ); + } ); + + it( 'shows transparent color when series hidden', () => { + const { container } = render( + + ); + + const colorIndicators = container.querySelectorAll( + '.prpl-line-chart__filter-color' + ); + // No series visible - background should be transparent + expect( colorIndicators[ 0 ] ).toHaveStyle( { + backgroundColor: 'transparent', + } ); + } ); + + it( 'applies outline with series color', () => { + const { container } = render( + + ); + + const colorIndicator = container.querySelector( + '.prpl-line-chart__filter-color' + ); + expect( colorIndicator ).toHaveStyle( { + outline: '1px solid #ff0000', + } ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles single series', () => { + const singleDataArgs = { + only: { color: '#abc', label: 'Only Series' }, + }; + + render( + + ); + + expect( screen.getByText( 'Only Series' ) ).toBeInTheDocument(); + } ); + + it( 'handles special characters in label', () => { + const specialDataArgs = { + special: { color: '#000', label: "Series & 'More'" }, + }; + + render( + + ); + + expect( + screen.getByText( "Series & 'More'" ) + ).toBeInTheDocument(); + } ); + + it( 'handles CSS variable colors', () => { + const cssVarDataArgs = { + cssVar: { + color: 'var(--custom-color)', + label: 'CSS Var Color', + }, + }; + + const { container } = render( + + ); + + const colorIndicator = container.querySelector( + '.prpl-line-chart__filter-color' + ); + expect( colorIndicator ).toHaveStyle( { + backgroundColor: 'var(--custom-color)', + } ); + } ); + } ); +} ); diff --git a/assets/src/components/LineChart/__tests__/LineChart.test.js b/assets/src/components/LineChart/__tests__/LineChart.test.js new file mode 100644 index 0000000000..708454fd63 --- /dev/null +++ b/assets/src/components/LineChart/__tests__/LineChart.test.js @@ -0,0 +1,543 @@ +/** + * Tests for LineChart Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import LineChart from '../index'; + +describe( 'LineChart', () => { + const mockData = { + series1: [ + { label: 'Jan', score: 10 }, + { label: 'Feb', score: 20 }, + { label: 'Mar', score: 30 }, + ], + }; + + const mockOptions = { + dataArgs: { + series1: { color: '#ff0000', label: 'Series 1' }, + }, + }; + + const multiSeriesData = { + series1: [ + { label: 'Jan', score: 10 }, + { label: 'Feb', score: 20 }, + { label: 'Mar', score: 30 }, + ], + series2: [ + { label: 'Jan', score: 5 }, + { label: 'Feb', score: 15 }, + { label: 'Mar', score: 25 }, + ], + }; + + const multiSeriesOptions = { + dataArgs: { + series1: { color: '#ff0000', label: 'Series 1' }, + series2: { color: '#00ff00', label: 'Series 2' }, + }, + }; + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart' ) + ).toBeInTheDocument(); + } ); + + it( 'renders SVG element', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__svg' ) + ).toBeInTheDocument(); + } ); + + it( 'renders SVG container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__svg-container' ) + ).toBeInTheDocument(); + } ); + + it( 'renders X axis', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__x-axis' ) + ).toBeInTheDocument(); + } ); + + it( 'renders Y axis', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__y-axis' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'X-axis labels', () => { + it( 'renders X-axis labels from data', () => { + render( ); + + expect( screen.getByText( 'Jan' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Feb' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Mar' ) ).toBeInTheDocument(); + } ); + + it( 'renders X-axis label groups', () => { + const { container } = render( + + ); + + const xLabels = container.querySelectorAll( + '.prpl-line-chart__x-label' + ); + expect( xLabels ).toHaveLength( 3 ); + } ); + + it( 'limits labels to ~6 for many data points', () => { + const manyPointsData = { + series1: Array.from( { length: 12 }, ( _, i ) => ( { + label: `Point ${ i + 1 }`, + score: i * 10, + } ) ), + }; + + const { container } = render( + + ); + + // With divider logic, not all labels will be rendered + const xLabels = container.querySelectorAll( + '.prpl-line-chart__x-label' + ); + expect( xLabels.length ).toBeLessThanOrEqual( 7 ); + } ); + } ); + + describe( 'Y-axis labels', () => { + it( 'renders Y-axis label groups', () => { + const { container } = render( + + ); + + const yLabels = container.querySelectorAll( + '.prpl-line-chart__y-label' + ); + expect( yLabels.length ).toBeGreaterThan( 0 ); + } ); + + it( 'includes 0 as minimum Y label', () => { + render( ); + + expect( screen.getByText( '0' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'series rendering', () => { + it( 'renders series polyline', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series' ) + ).toBeInTheDocument(); + } ); + + it( 'renders polyline with correct stroke color', () => { + const { container } = render( + + ); + + const polyline = container.querySelector( + '.prpl-line-chart__series polyline' + ); + expect( polyline ).toHaveAttribute( 'stroke', '#ff0000' ); + } ); + + it( 'renders polyline with fill none', () => { + const { container } = render( + + ); + + const polyline = container.querySelector( + '.prpl-line-chart__series polyline' + ); + expect( polyline ).toHaveAttribute( 'fill', 'none' ); + } ); + + it( 'renders series with class including key', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'multiple series', () => { + it( 'renders multiple series', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '.prpl-line-chart__series--series2' ) + ).toBeInTheDocument(); + } ); + + it( 'shows filters for multiple series', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filters' ) + ).toBeInTheDocument(); + } ); + + it( 'does not show filters for single series', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__filters' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'series visibility toggle', () => { + it( 'hides series when filter unchecked', () => { + const { container } = render( + + ); + + // Initially both series visible + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).toBeInTheDocument(); + + // Click to uncheck series1 + const checkbox = container.querySelector( + '#prpl-chart-filter-series1' + ); + fireEvent.click( checkbox ); + + // Series1 should now be hidden + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).not.toBeInTheDocument(); + } ); + + it( 'shows series when filter checked again', () => { + const { container } = render( + + ); + + // Uncheck series1 + const checkbox = container.querySelector( + '#prpl-chart-filter-series1' + ); + fireEvent.click( checkbox ); + + // Series1 hidden + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).not.toBeInTheDocument(); + + // Check series1 again + fireEvent.click( checkbox ); + + // Series1 visible again + expect( + container.querySelector( '.prpl-line-chart__series--series1' ) + ).toBeInTheDocument(); + } ); + + it( 'maintains other series visibility when toggling one', () => { + const { container } = render( + + ); + + // Uncheck series1 + const checkbox = container.querySelector( + '#prpl-chart-filter-series1' + ); + fireEvent.click( checkbox ); + + // Series2 should still be visible + expect( + container.querySelector( '.prpl-line-chart__series--series2' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'options', () => { + it( 'uses default height of 300', () => { + const { container } = render( + + ); + + const svg = container.querySelector( '.prpl-line-chart__svg' ); + const viewBox = svg.getAttribute( 'viewBox' ); + // Default: height=300, aspectRatio=2, axisOffset=16 + // svgHeight = 300 + 16*2 = 332 + expect( viewBox ).toContain( '332' ); + } ); + + it( 'accepts custom height', () => { + const customOptions = { + ...mockOptions, + height: 400, + }; + + const { container } = render( + + ); + + const svg = container.querySelector( '.prpl-line-chart__svg' ); + const viewBox = svg.getAttribute( 'viewBox' ); + // svgHeight = 400 + 16*2 = 432 + expect( viewBox ).toContain( '432' ); + } ); + + it( 'accepts custom aspect ratio', () => { + const customOptions = { + ...mockOptions, + aspectRatio: 3, + }; + + const { container } = render( + + ); + + const svg = container.querySelector( '.prpl-line-chart__svg' ); + const viewBox = svg.getAttribute( 'viewBox' ); + // svgWidth = 300*3 + 16*2 = 932 + expect( viewBox ).toContain( '932' ); + } ); + + it( 'accepts custom stroke width', () => { + const customOptions = { + ...mockOptions, + strokeWidth: 8, + }; + + const { container } = render( + + ); + + const polyline = container.querySelector( + '.prpl-line-chart__series polyline' + ); + expect( polyline ).toHaveAttribute( 'stroke-width', '8' ); + } ); + + it( 'accepts custom axis color', () => { + const customOptions = { + ...mockOptions, + axisColor: '#333333', + }; + + const { container } = render( + + ); + + const xAxisLine = container.querySelector( + '.prpl-line-chart__x-axis line' + ); + expect( xAxisLine ).toHaveAttribute( 'stroke', '#333333' ); + } ); + + it( 'shows filters label when provided', () => { + const customOptions = { + ...multiSeriesOptions, + filtersLabel: 'Filter by:', + }; + + render( + + ); + + expect( screen.getByText( 'Filter by:' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'styling', () => { + it( 'applies width 100% to container', () => { + const { container } = render( + + ); + + const chart = container.querySelector( '.prpl-line-chart' ); + expect( chart ).toHaveStyle( { width: '100%' } ); + } ); + + it( 'applies width 100% to SVG container', () => { + const { container } = render( + + ); + + const svgContainer = container.querySelector( + '.prpl-line-chart__svg-container' + ); + expect( svgContainer ).toHaveStyle( { width: '100%' } ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles empty data', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart' ) + ).toBeInTheDocument(); + } ); + + it( 'handles two data points (minimum viable)', () => { + const twoPointData = { + series1: [ + { label: 'Start', score: 50 }, + { label: 'End', score: 75 }, + ], + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series' ) + ).toBeInTheDocument(); + } ); + + it( 'handles zero scores', () => { + const zeroData = { + series1: [ + { label: 'A', score: 0 }, + { label: 'B', score: 0 }, + { label: 'C', score: 0 }, + ], + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series' ) + ).toBeInTheDocument(); + } ); + + it( 'handles max score near 100', () => { + // Tests the 70-100 padding logic + const nearMaxData = { + series1: [ + { label: 'A', score: 75 }, + { label: 'B', score: 85 }, + ], + }; + + render( + + ); + + // Should render with Y-axis going to 100 + expect( screen.getByText( '100' ) ).toBeInTheDocument(); + } ); + + it( 'handles high scores', () => { + const highData = { + series1: [ + { label: 'A', score: 500 }, + { label: 'B', score: 1000 }, + ], + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-line-chart__series' ) + ).toBeInTheDocument(); + } ); + + it( 'handles data with special characters in labels', () => { + const specialData = { + series1: [ + { label: "Jan's ", score: 10 }, + { label: 'Feb & Mar', score: 20 }, + ], + }; + + render( + + ); + + expect( screen.getByText( "Jan's " ) ).toBeInTheDocument(); + expect( screen.getByText( 'Feb & Mar' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'SVG viewBox', () => { + it( 'calculates correct viewBox dimensions', () => { + const customOptions = { + ...mockOptions, + height: 200, + aspectRatio: 2, + axisOffset: 10, + }; + + const { container } = render( + + ); + + const svg = container.querySelector( '.prpl-line-chart__svg' ); + // svgWidth = 200*2 + 10*2 = 420 + // svgHeight = 200 + 10*2 = 220 + expect( svg ).toHaveAttribute( 'viewBox', '0 0 420 220' ); + } ); + } ); +} ); diff --git a/assets/src/components/LineChart/index.js b/assets/src/components/LineChart/index.js new file mode 100644 index 0000000000..c45b1306d9 --- /dev/null +++ b/assets/src/components/LineChart/index.js @@ -0,0 +1,363 @@ +/** + * LineChart Component + * + * Displays an SVG line chart with multiple series and filter checkboxes. + */ + +import { useState, useMemo, useCallback } from '@wordpress/element'; +import ChartFilters from './ChartFilters'; + +/** + * Default options for the chart. + */ +const DEFAULT_OPTIONS = { + aspectRatio: 2, + height: 300, + axisOffset: 16, + strokeWidth: 4, + dataArgs: {}, + axisColor: 'var(--prpl-color-border)', + rulersColor: 'var(--prpl-color-border)', + filtersLabel: '', +}; + +/** + * LineChart component. + * + * @param {Object} props - Component props. + * @param {Object} props.data - Chart data object with series keys. + * @param {Object} props.options - Chart options. + * @return {JSX.Element} The LineChart component. + */ +export default function LineChart( { data, options: propOptions } ) { + const options = useMemo( + () => ( { + ...DEFAULT_OPTIONS, + ...propOptions, + } ), + [ propOptions ] + ); + + const [ visibleSeries, setVisibleSeries ] = useState( () => + Object.keys( options.dataArgs ) + ); + + /** + * Toggle series visibility. + * + * @param {string} key - The series key to toggle. + */ + const toggleSeries = useCallback( ( key ) => { + setVisibleSeries( ( prev ) => + prev.includes( key ) + ? prev.filter( ( k ) => k !== key ) + : [ ...prev, key ] + ); + }, [] ); + + /** + * Get the maximum value from visible series data. + * + * @return {number} The maximum value. + */ + const getMaxValue = useCallback( () => { + return Object.keys( data ).reduce( ( max, key ) => { + if ( visibleSeries.includes( key ) ) { + return Math.max( + max, + data[ key ].reduce( + ( _max, item ) => Math.max( _max, item.score ), + 0 + ) + ); + } + return max; + }, 0 ); + }, [ data, visibleSeries ] ); + + /** + * Get padded maximum value for axis scaling. + * + * @return {number} The padded maximum value. + */ + const getMaxValuePadded = useCallback( () => { + const max = getMaxValue(); + const maxValue = 100 > max && 70 < max ? 100 : max; + return Math.max( + 100 === maxValue ? 100 : parseInt( maxValue * 1.1, 10 ), + 1 + ); + }, [ getMaxValue ] ); + + /** + * Get the optimal Y-axis step divider (3, 4, or 5). + * + * @return {number} The step divider. + */ + const getYLabelsStepsDivider = useCallback( () => { + const maxValuePadded = getMaxValuePadded(); + const stepsRemainders = { + 4: maxValuePadded % 4, + 5: maxValuePadded % 5, + 3: maxValuePadded % 3, + }; + const smallestRemainder = Math.min( + ...Object.values( stepsRemainders ) + ); + return parseInt( + Object.keys( stepsRemainders ).find( + ( key ) => stepsRemainders[ key ] === smallestRemainder + ), + 10 + ); + }, [ getMaxValuePadded ] ); + + /** + * Get Y-axis labels. + * + * @return {number[]} Array of Y-axis label values. + */ + const getYLabels = useCallback( () => { + const maxValuePadded = getMaxValuePadded(); + const yLabelsStepsDivider = getYLabelsStepsDivider(); + const yLabelsStep = maxValuePadded / yLabelsStepsDivider; + const yLabels = []; + + if ( 100 === maxValuePadded || 15 > maxValuePadded ) { + for ( let i = 0; i <= yLabelsStepsDivider; i++ ) { + yLabels.push( parseInt( yLabelsStep * i, 10 ) ); + } + } else { + for ( let i = 0; i <= yLabelsStepsDivider; i++ ) { + yLabels.push( + Math.min( maxValuePadded, Math.round( yLabelsStep * i ) ) + ); + } + } + + return yLabels; + }, [ getMaxValuePadded, getYLabelsStepsDivider ] ); + + /** + * Calculate Y coordinate for a value. + * + * @param {number} value - The data value. + * @return {number} The Y coordinate. + */ + const calcYCoordinate = useCallback( + ( value ) => { + const maxValuePadded = getMaxValuePadded(); + const multiplier = + ( options.height - options.axisOffset * 2 ) / options.height; + const yCoordinate = + ( maxValuePadded - value * multiplier ) * + ( options.height / maxValuePadded ) - + options.axisOffset; + return yCoordinate - options.strokeWidth / 2; + }, + [ + getMaxValuePadded, + options.height, + options.axisOffset, + options.strokeWidth, + ] + ); + + /** + * Get distance between X-axis points. + * + * @return {number} The distance. + */ + const getXDistanceBetweenPoints = useCallback( () => { + const firstKey = Object.keys( data )[ 0 ]; + if ( ! firstKey || ! data[ firstKey ] ) { + return 0; + } + return Math.round( + ( options.height * options.aspectRatio - 3 * options.axisOffset ) / + ( data[ firstKey ].length - 1 ) + ); + }, [ data, options.height, options.aspectRatio, options.axisOffset ] ); + + // Calculate SVG viewBox dimensions + const svgWidth = parseInt( + options.height * options.aspectRatio + options.axisOffset * 2, + 10 + ); + const svgHeight = parseInt( options.height + options.axisOffset * 2, 10 ); + + // Get X-axis labels data + const firstSeriesKey = Object.keys( data )[ 0 ]; + const firstSeriesData = firstSeriesKey ? data[ firstSeriesKey ] : []; + const dataLength = firstSeriesData.length; + const labelsXDivider = Math.max( 1, Math.round( dataLength / 6 ) ); + + const containerStyle = { + width: '100%', + }; + + const svgContainerStyle = { + width: '100%', + }; + + return ( +
+ { Object.keys( options.dataArgs ).length > 1 && ( + + ) } +
+ + { /* X Axis Line */ } + + + + + { /* Y Axis Line */ } + + + + + { /* X Axis Labels and Rulers */ } + { firstSeriesData.map( ( item, index ) => { + const labelXCoordinate = + getXDistanceBetweenPoints() * index + + options.axisOffset * 2; + + // Only show up to 6 labels + if ( + dataLength > 6 && + index !== 0 && + index % labelsXDivider !== 0 + ) { + return null; + } + + return ( + + + { item.label } + + { index !== 0 && ( + + ) } + + ); + } ) } + + { /* Y Axis Labels and Rulers */ } + { getYLabels().map( ( yLabel, index ) => { + const yLabelCoordinate = calcYCoordinate( yLabel ); + + return ( + + + { yLabel } + + { index !== 0 && ( + + ) } + + ); + } ) } + + { /* Polylines for each series */ } + { Object.keys( data ).map( ( key ) => { + if ( ! visibleSeries.includes( key ) ) { + return null; + } + + const points = data[ key ] + .map( ( item, index ) => { + const xCoordinate = + options.axisOffset * 3 + + getXDistanceBetweenPoints() * index; + const yCoordinate = calcYCoordinate( + item.score + ); + return `${ xCoordinate },${ yCoordinate }`; + } ) + .join( ' ' ); + + return ( + + + + ); + } ) } + +
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/NextButton.js b/assets/src/components/OnboardingWizard/NextButton.js new file mode 100644 index 0000000000..0d0d879d16 --- /dev/null +++ b/assets/src/components/OnboardingWizard/NextButton.js @@ -0,0 +1,120 @@ +/** + * NextButton Component + * + * Reusable Next button for onboarding wizard steps. + * Can be placed anywhere within a step for custom layouts. + * + * @package + */ + +import { useEffect, useRef, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * NextButton component for use inside steps. + * + * @param {Object} props - Component props. + * @param {Function} props.onNext - Callback when next is clicked. + * @param {Function} props.canProceed - Function to check if step can proceed. + * @param {Object} props.wizardState - Current wizard state. + * @param {string} props.buttonText - Custom button text. + * @param {string} props.buttonClass - Custom button class. + * @param {boolean} props.isLoading - Whether to show loading spinner. + * @param {boolean} props.wrapInFooter - Whether to wrap in .tour-footer div. + * @return {JSX.Element} Next button component. + */ +export default function NextButton( { + onNext, + canProceed = () => true, + wizardState, + buttonText, + buttonClass = 'prpl-btn-primary', + isLoading = false, + wrapInFooter = true, +} ) { + const nextButtonRef = useRef( null ); + + /** + * Update next button state based on canProceed. + */ + const updateNextButton = useCallback( () => { + if ( ! nextButtonRef.current ) { + return; + } + + const canAdvance = canProceed( wizardState ); + if ( canAdvance ) { + nextButtonRef.current.classList.remove( 'prpl-btn-disabled' ); + } else { + nextButtonRef.current.classList.add( 'prpl-btn-disabled' ); + } + }, [ canProceed, wizardState ] ); + + // Update button state when canProceed changes. + useEffect( () => { + updateNextButton(); + }, [ updateNextButton ] ); + + /** + * Handle disabled button click (show error indicator). + * + * @param {Event} e - Click event. + */ + const handleDisabledClick = ( e ) => { + if ( + nextButtonRef.current?.classList.contains( 'prpl-btn-disabled' ) + ) { + e.preventDefault(); + e.stopPropagation(); + // Show error indicator (used by WelcomeStep for privacy checkbox). + const requiredIndicator = document.querySelector( + '.prpl-privacy-checkbox-wrapper .prpl-required-indicator' + ); + if ( requiredIndicator ) { + requiredIndicator.classList.add( + 'prpl-required-indicator-active' + ); + } + } + }; + + const buttonContent = ( +
+ { isLoading && ( + + ) } + +
+ ); + + if ( wrapInFooter ) { + return
{ buttonContent }
; + } + + return buttonContent; +} diff --git a/assets/src/components/OnboardingWizard/OnboardTask.js b/assets/src/components/OnboardingWizard/OnboardTask.js new file mode 100644 index 0000000000..711c7e3e10 --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardTask.js @@ -0,0 +1,610 @@ +/** + * OnboardTask Component + * + * Individual task component for MoreTasksStep. + * Handles task form toggling, file uploads, and completion. + * + * @package + */ + +import { useState, useEffect, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useTaskCompletion } from '../../hooks/useTaskCompletion'; +import TASK_FORMS from './TaskForms'; + +/** + * OnboardTask component. + * + * @param {Object} props - Component props. + * @param {Object} props.task - Task data. + * @param {Object} props.config - Wizard configuration. + * @param {Function} props.onComplete - Callback when task is completed. + * @param {Function} props.onOpenChange - Callback when task open state changes. + * @param {boolean} props.forceOpen - If true, render in open state. + * @param {boolean} props.disableActionButton - If true, disable the template's action button by default. + * @return {JSX.Element} OnboardTask component. + */ +export default function OnboardTask( { + task, + config, + onComplete, + onOpenChange, + forceOpen = false, + disableActionButton = false, +} ) { + const { ajaxUrl, nonce } = config; + const { completeTask } = useTaskCompletion( { + ajaxUrl, + nonce, + } ); + + const [ isOpen, setIsOpen ] = useState( forceOpen ); + const [ isCompleted, setIsCompleted ] = useState( false ); + const [ formValues, setFormValues ] = useState( {} ); + const taskContentRef = useRef( null ); + + // Use template HTML from task data if available, otherwise fetch it. + const [ templateHtml, setTemplateHtml ] = useState( + task?.template_html || '' + ); + const [ isLoadingTemplate, setIsLoadingTemplate ] = useState( false ); + + // Fetch template if not provided in task data. + useEffect( () => { + if ( ! task?.task_id || task?.template_html ) { + return; + } + + const fetchTemplate = async () => { + setIsLoadingTemplate( true ); + try { + const formData = new FormData(); + formData.append( + 'action', + 'progress_planner_get_task_template' + ); + formData.append( 'nonce', nonce ); + formData.append( 'task_id', task.task_id ); + formData.append( 'task_data', JSON.stringify( task ) ); + + const response = await fetch( ajaxUrl, { + method: 'POST', + body: formData, + } ).then( ( res ) => res.json() ); + + if ( response.success && response.data?.html ) { + setTemplateHtml( response.data.html ); + } + } catch ( error ) { + console.error( 'Failed to fetch task template:', error ); + } finally { + setIsLoadingTemplate( false ); + } + }; + + fetchTemplate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ task?.task_id, task?.template_html, ajaxUrl, nonce ] ); + + /** + * Handle task completion. + */ + const handleComplete = async () => { + if ( ! task?.task_id ) { + return; + } + + try { + await completeTask( task.task_id, formValues ); + setIsCompleted( true ); + // Close the task view and return to task list. + setIsOpen( false ); + onOpenChange?.( false ); + onComplete?.( task.task_id ); + } catch ( error ) { + console.error( 'Failed to complete task:', error ); + } + }; + + /** + * Handle open task. + */ + const handleOpen = () => { + setIsOpen( true ); + onOpenChange?.( true ); + }; + + /** + * Handle close task. + */ + const handleClose = () => { + setIsOpen( false ); + onOpenChange?.( false ); + }; + + /** + * Validate if file matches the accepted file types from the input. + * + * @param {File} file - The file to validate. + * @param {HTMLInputElement} fileInput - The file input element. + * @return {boolean} True if file extension is supported. + */ + const isValidFile = ( file, fileInput ) => { + if ( ! fileInput || ! fileInput.accept ) { + return true; + } + + const acceptedTypes = fileInput.accept + .split( ',' ) + .map( ( type ) => type.trim() ); + const fileName = file.name.toLowerCase(); + + return acceptedTypes.some( ( type ) => { + if ( type.startsWith( '.' ) ) { + return fileName.endsWith( type ); + } else if ( type.includes( '/' ) ) { + return file.type === type; + } + return false; + } ); + }; + + /** + * Upload file to WordPress media library. + * + * @param {File} file - The file to upload. + * @param {HTMLElement} statusDiv - The status div element. + * @param {HTMLElement} el - The container element. + * @return {Promise} Promise resolving to the uploaded file response. + */ + const uploadFile = async ( file, statusDiv, el ) => { + const fileInput = el.querySelector( 'input[type="file"]' ); + + if ( ! isValidFile( file, fileInput ) ) { + const acceptedTypes = fileInput?.accept || 'supported file types'; + statusDiv.textContent = `Invalid file type. Please upload: ${ acceptedTypes }`; + return null; + } + + statusDiv.textContent = `Uploading ${ file.name }...`; + statusDiv.style.display = ''; + + const formData = new FormData(); + formData.append( 'file', file ); + formData.append( 'prplFileUpload', '1' ); + + try { + const response = await fetch( '/wp-json/wp/v2/media', { + method: 'POST', + headers: { + 'X-WP-Nonce': config.nonceWPAPI, + }, + body: formData, + credentials: 'same-origin', + } ); + + if ( response.status !== 201 ) { + throw new Error( 'Failed to upload file' ); + } + + const result = await response.json(); + statusDiv.style.display = 'none'; + + // Update file preview. + const previewDiv = el.querySelector( '.prpl-file-preview' ); + if ( previewDiv ) { + previewDiv.innerHTML = `${ file.name }`; + previewDiv.style.display = 'block'; + } + + // Update drop zone styling. + const dropZone = el.querySelector( '.prpl-file-drop-zone' ); + if ( dropZone ) { + dropZone.classList.add( 'has-image' ); + const removeBtn = dropZone.querySelector( + '.prpl-file-remove-btn' + ); + if ( removeBtn ) { + removeBtn.hidden = false; + } + } + + return result; + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Upload error:', error ); + statusDiv.textContent = `Error: ${ error.message }`; + return null; + } + }; + + /** + * Remove uploaded file and reset drop zone. + * + * @param {HTMLElement} dropZone - The drop zone element. + * @param {HTMLElement} previewDiv - The preview div element. + */ + const removeUploadedFile = ( dropZone, previewDiv ) => { + previewDiv.innerHTML = ''; + previewDiv.style.display = 'none'; + + dropZone.classList.remove( 'has-image' ); + + const removeBtn = dropZone.querySelector( '.prpl-file-remove-btn' ); + if ( removeBtn ) { + removeBtn.hidden = true; + } + + const fileInput = dropZone.querySelector( 'input[type="file"]' ); + if ( fileInput ) { + fileInput.value = ''; + } + + const postIdInput = dropZone.querySelector( 'input[name="post_id"]' ); + if ( postIdInput ) { + postIdInput.value = ''; + postIdInput.dispatchEvent( + new CustomEvent( 'change', { bubbles: true } ) + ); + } + + const statusDiv = dropZone.querySelector( '.prpl-upload-status' ); + if ( statusDiv ) { + statusDiv.style.display = ''; + statusDiv.textContent = ''; + } + }; + + /** + * Set up file upload functionality for drop zone. + * + * @param {HTMLElement} el - The container element. + */ + const setupFileUpload = ( el ) => { + const uploadContainer = el.querySelector( '[data-upload-field]' ); + if ( ! uploadContainer ) { + return; + } + + // Prevent duplicate event listener setup. + if ( uploadContainer.dataset.uploadInitialized ) { + return; + } + uploadContainer.dataset.uploadInitialized = 'true'; + + const fileInput = uploadContainer.querySelector( 'input[type="file"]' ); + const statusDiv = uploadContainer.querySelector( + '.prpl-upload-status' + ); + const previewDiv = + uploadContainer.querySelector( '.prpl-file-preview' ); + + // Drag and drop visual feedback. + [ 'dragenter', 'dragover' ].forEach( ( eventName ) => { + uploadContainer.addEventListener( eventName, ( e ) => { + e.preventDefault(); + e.stopPropagation(); + uploadContainer.classList.add( 'dragover' ); + } ); + } ); + + // Handle dragleave. + uploadContainer.addEventListener( 'dragleave', ( e ) => { + e.preventDefault(); + e.stopPropagation(); + uploadContainer.classList.remove( 'dragover' ); + } ); + + // Handle drop. + uploadContainer.addEventListener( 'drop', async ( e ) => { + e.preventDefault(); + e.stopPropagation(); + uploadContainer.classList.remove( 'dragover' ); + const file = e.dataTransfer.files[ 0 ]; + if ( file ) { + const result = await uploadFile( file, statusDiv, el ); + if ( result && fileInput ) { + // Update hidden post_id input for site icon task. + const postIdInput = fileInput.nextElementSibling; + if ( postIdInput && postIdInput.name === 'post_id' ) { + postIdInput.value = result.id; + postIdInput.dispatchEvent( + new CustomEvent( 'change', { bubbles: true } ) + ); + } + } + } + } ); + + // Handle file input change. + fileInput?.addEventListener( 'change', async ( e ) => { + const file = e.target.files[ 0 ]; + if ( file ) { + const result = await uploadFile( file, statusDiv, el ); + if ( result ) { + // Update hidden post_id input for site icon task. + const postIdInput = fileInput.nextElementSibling; + if ( postIdInput && postIdInput.name === 'post_id' ) { + postIdInput.value = result.id; + postIdInput.dispatchEvent( + new CustomEvent( 'change', { bubbles: true } ) + ); + } + } + } + } ); + + // Handle remove button. + const removeBtn = uploadContainer.querySelector( + '.prpl-file-remove-btn' + ); + removeBtn?.addEventListener( 'click', () => { + removeUploadedFile( uploadContainer, previewDiv ); + } ); + }; + + // Look up React form component for this task. + const TaskFormComponent = task?.task_id ? TASK_FORMS[ task.task_id ] : null; + + /** + * Handle React form submission. + * + * @param {Event} e - The form submit event. + */ + const handleFormSubmit = ( e ) => { + e.preventDefault(); + const formData = new FormData( e.target ); + setFormValues( Object.fromEntries( formData.entries() ) ); + setTimeout( () => handleComplete(), 0 ); + }; + + if ( isOpen ) { + // Render with native React form component if available. + if ( TaskFormComponent ) { + return ( +
+
+
{ + if ( el ) { + setupFileUpload( el ); + } + } } + > + +
+ + +
+ +
+
+ ); + } + + return ( +
+
+ { isLoadingTemplate && ( +
+ +
+ ) } + { ! isLoadingTemplate && templateHtml && ( +
{ + // Handle form submission and file uploads. + if ( + e.target.classList.contains( + 'prpl-complete-task-btn' + ) + ) { + const form = e.target.closest( 'form' ); + if ( form ) { + const formData = new FormData( form ); + setFormValues( + Object.fromEntries( + formData.entries() + ) + ); + // Trigger completion after form values are set. + setTimeout( () => handleComplete(), 0 ); + } + } + } } + onKeyDown={ ( e ) => { + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + const target = e.target; + if ( + target.classList.contains( + 'prpl-complete-task-btn' + ) + ) { + const form = target.closest( 'form' ); + if ( form ) { + const formData = new FormData( + form + ); + setFormValues( + Object.fromEntries( + formData.entries() + ) + ); + setTimeout( + () => handleComplete(), + 0 + ); + } + } + } + } } + tabIndex={ -1 } + ref={ ( el ) => { + if ( el && templateHtml ) { + // Set up file upload functionality (has its own guard). + setupFileUpload( el ); + + // Prevent duplicate button creation on re-renders. + if ( + el.querySelector( '.prpl-task-buttons' ) + ) { + return; + } + + const actionBtn = el.querySelector( + '.prpl-complete-task-btn' + ); + + if ( actionBtn ) { + // Create button wrapper like develop branch does. + const buttonWrapper = + document.createElement( 'div' ); + buttonWrapper.className = + 'prpl-task-buttons'; + + // Create close button. + const closeBtn = + document.createElement( 'button' ); + closeBtn.type = 'button'; + closeBtn.className = + 'prpl-btn prpl-task-close-btn'; + closeBtn.innerHTML = + ' ' + + ( config?.l10n + ?.backToRecommendations || + 'Back to recommendations' ); + closeBtn.addEventListener( + 'click', + handleClose + ); + + // Insert wrapper before action button, then move buttons into it. + actionBtn.parentNode.insertBefore( + buttonWrapper, + actionBtn + ); + buttonWrapper.appendChild( closeBtn ); + buttonWrapper.appendChild( actionBtn ); + + // Disable action button by default if requested. + if ( disableActionButton ) { + actionBtn.disabled = true; + actionBtn.classList.add( + 'prpl-btn-disabled' + ); + + // Enable button when user makes a selection. + const enableButton = () => { + actionBtn.disabled = false; + actionBtn.classList.remove( + 'prpl-btn-disabled' + ); + }; + + // Watch for form input changes. + const inputs = el.querySelectorAll( + 'input, select, textarea' + ); + inputs.forEach( ( input ) => { + input.addEventListener( + 'change', + enableButton + ); + input.addEventListener( + 'input', + enableButton + ); + } ); + + // Watch for file uploads. + const fileInputs = + el.querySelectorAll( + 'input[type="file"]' + ); + fileInputs.forEach( + ( fileInput ) => { + fileInput.addEventListener( + 'change', + enableButton + ); + } + ); + + // Watch for custom events (e.g., from media uploader). + el.addEventListener( + 'prpl-task-input-changed', + enableButton + ); + } + } + } + } } + /> + ) } + { ! isLoadingTemplate && ! templateHtml && ( + <> + { task.title &&

{ task.title }

} + { task.url && ( + + { task.action_label || + __( 'Do it', 'progress-planner' ) } + + ) } + + ) } +
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/assets/src/components/OnboardingWizard/OnboardingNavigation.js b/assets/src/components/OnboardingWizard/OnboardingNavigation.js new file mode 100644 index 0000000000..3984add2d6 --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardingNavigation.js @@ -0,0 +1,61 @@ +/** + * OnboardingNavigation Component + * + * Left sidebar navigation showing wizard steps. + * Steps are display-only indicators, not clickable buttons. + * + * @package + */ + +/** + * OnboardingNavigation component. + * + * @param {Object} props - Component props. + * @param {Array} props.steps - Array of step definitions. + * @param {number} props.currentStep - Current step index. + * @param {string} props.logoHtml - Logo HTML from PHP. + * @return {JSX.Element} Navigation component. + */ +export default function OnboardingNavigation( { + steps, + currentStep, + logoHtml, +} ) { + return ( +
+
+
+ { steps[ currentStep ]?.title || '' } +
+
    + { steps.map( ( step, index ) => { + const isActive = index === currentStep; + const isCompleted = index < currentStep; + const stepNumber = index + 1; + + return ( +
  1. + + { isCompleted ? '✓' : stepNumber } + + + { step.title } + +
  2. + ); + } ) } +
+
+
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/OnboardingStep.js b/assets/src/components/OnboardingWizard/OnboardingStep.js new file mode 100644 index 0000000000..c96cacb306 --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardingStep.js @@ -0,0 +1,54 @@ +/** + * OnboardingStep Base Component + * + * Base component for all onboarding wizard steps. + * Provides common functionality like canProceed, updateNextButton, etc. + * + * @package + */ + +import NextButton from './NextButton'; + +/** + * Base step component. + * + * This is a utility component that provides common step functionality. + * Individual step components should use these utilities. + * + * @param {Object} props - Component props. + * @param {Object} props.wizardState - Current wizard state. + * @param {Function} props.onNext - Callback when next is clicked. + * @param {Function} props.canProceed - Function to check if step can proceed. + * @param {string} props.buttonText - Custom button text (defaults to "Next"). + * @param {string} props.buttonClass - Custom button class (defaults to "prpl-btn-primary"). + * @param {boolean} props.isLoading - Whether to show loading spinner. + * @param {boolean} props.hideFooter - If true, don't render the footer (step will render its own button). + * @param {Object} props.children - Step content. + * @return {JSX.Element} Step component. + */ +export default function OnboardingStep( { + wizardState, + onNext, + canProceed = () => true, + buttonText, + buttonClass = 'prpl-btn-primary', + isLoading = false, + hideFooter = false, + children, +} ) { + return ( +
+ { children } + { ! hideFooter && ( + + ) } +
+ ); +} diff --git a/assets/src/components/OnboardingWizard/QuitConfirmation.js b/assets/src/components/OnboardingWizard/QuitConfirmation.js new file mode 100644 index 0000000000..2f22ab9f83 --- /dev/null +++ b/assets/src/components/OnboardingWizard/QuitConfirmation.js @@ -0,0 +1,112 @@ +/** + * QuitConfirmation Component + * + * Confirmation dialog when user tries to close the wizard. + * + * @package + */ + +import { __, sprintf } from '@wordpress/i18n'; + +/** + * QuitConfirmation component. + * + * @param {Object} props - Component props. + * @param {Function} props.onConfirm - Callback when user confirms quit. + * @param {Function} props.onCancel - Callback when user cancels quit. + * @param {Object} props.config - Wizard configuration. + * @return {JSX.Element} Quit confirmation dialog. + */ +export default function QuitConfirmation( { onConfirm, onCancel, config } ) { + const brandingName = + config?.l10n?.brandingName || + __( 'Progress Planner', 'progress-planner' ); + const baseUrl = config?.baseUrl || ''; + + return ( +
+
+
+
+ + + + + +
+

+ { __( + 'Are you sure you want to quit?', + 'progress-planner' + ) } +

+

+ { sprintf( + /* translators: %s: Progress Planner name */ + __( + 'You need to finish the onboarding before you can work with the %s and start improving your site.', + 'progress-planner' + ), + brandingName + ) } +

+
+
+
+ + +
+
+
+
+
+ +
+
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/TaskForms/BlogDescriptionForm.js b/assets/src/components/OnboardingWizard/TaskForms/BlogDescriptionForm.js new file mode 100644 index 0000000000..e4231af322 --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/BlogDescriptionForm.js @@ -0,0 +1,37 @@ +/** + * BlogDescriptionForm Component + * + * Renders a text input for the site tagline (blog description). + * Replaces views/onboarding/tasks/core-blogdescription.php. + * + * @param {Object} props Component props. + * @param {Object} props.task The task data with site_description. + * @return {JSX.Element} The blog description form. + */ + +import { __ } from '@wordpress/i18n'; + +export default function BlogDescriptionForm( { task } ) { + return ( +
+
+

{ task.title }

+

+ { __( + "In a few words, explain what this site is about. This information is used in your website's schema and RSS feeds, and can be displayed on your site. The tagline typically is your site's mission statement.", + 'progress-planner' + ) } +

+
+ +
+ ); +} diff --git a/assets/src/components/OnboardingWizard/TaskForms/LocaleForm.js b/assets/src/components/OnboardingWizard/TaskForms/LocaleForm.js new file mode 100644 index 0000000000..9179e4e378 --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/LocaleForm.js @@ -0,0 +1,46 @@ +/** + * LocaleForm Component + * + * Renders a select dropdown for locale/language selection. + * Fetches options from the locale-options REST endpoint. + * Replaces views/onboarding/tasks/select-locale.php. + * + * @param {Object} props Component props. + * @param {Object} props.task The task data. + * @return {JSX.Element} The locale form. + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; + +export default function LocaleForm( { task } ) { + const [ options, setOptions ] = useState( [] ); + + useEffect( () => { + apiFetch( { path: '/progress-planner/v1/locale-options' } ).then( + ( data ) => { + setOptions( data ); + } + ); + }, [] ); + + return ( +
+

{ task.title }

+

+ { __( + 'Your locale determines the language and formatting your visitors see, such as date structures and currency. Setting this helps your audience feel right at home. Choose your preferred language and region.', + 'progress-planner' + ) } +

+ +
+ ); +} diff --git a/assets/src/components/OnboardingWizard/TaskForms/SiteIconForm.js b/assets/src/components/OnboardingWizard/TaskForms/SiteIconForm.js new file mode 100644 index 0000000000..b21bee9fee --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/SiteIconForm.js @@ -0,0 +1,75 @@ +/** + * SiteIconForm Component + * + * Renders a file upload zone for the site icon. + * Replaces views/onboarding/tasks/core-siteicon.php. + * + * @param {Object} props Component props. + * @param {Object} props.task The task data. + * @return {JSX.Element} The site icon form. + */ + +import { __ } from '@wordpress/i18n'; + +export default function SiteIconForm( { task } ) { + return ( +
+

{ task.title }

+

+ { __( + 'Upload an image to make your site stand out.', + 'progress-planner' + ) } +

+
+ + + +

+ { __( 'Drag and drop a file here or', 'progress-planner' ) }{ ' ' } + + + + { __( + 'Recommended dimensions: 512 x 521 pixels', + 'progress-planner' + ) } + + + PNG, ICO, WEBP + + +

+ + +
+
+ +
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/TaskForms/TimezoneForm.js b/assets/src/components/OnboardingWizard/TaskForms/TimezoneForm.js new file mode 100644 index 0000000000..b4a77110c4 --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/TimezoneForm.js @@ -0,0 +1,46 @@ +/** + * TimezoneForm Component + * + * Renders a select dropdown for timezone selection. + * Fetches options from the timezone-options REST endpoint. + * Replaces views/onboarding/tasks/select-timezone.php. + * + * @param {Object} props Component props. + * @param {Object} props.task The task data. + * @return {JSX.Element} The timezone form. + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; + +export default function TimezoneForm( { task } ) { + const [ options, setOptions ] = useState( [] ); + + useEffect( () => { + apiFetch( { path: '/progress-planner/v1/timezone-options' } ).then( + ( data ) => { + setOptions( data ); + } + ); + }, [] ); + + return ( +
+

{ task.title }

+

+ { __( + "Setting your timezone ensures that scheduled posts and automated updates happen exactly when you expect them to. It keeps your site's clock synced with your local time. Pick your city or offset now!", + 'progress-planner' + ) } +

+ +
+ ); +} diff --git a/assets/src/components/OnboardingWizard/TaskForms/__tests__/BlogDescriptionForm.test.js b/assets/src/components/OnboardingWizard/TaskForms/__tests__/BlogDescriptionForm.test.js new file mode 100644 index 0000000000..9002753ece --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/__tests__/BlogDescriptionForm.test.js @@ -0,0 +1,66 @@ +/** + * Tests for BlogDescriptionForm Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import BlogDescriptionForm from '../BlogDescriptionForm'; + +describe( 'BlogDescriptionForm', () => { + const defaultTask = { + task_id: 'core-blogdescription', + title: 'Set your tagline', + site_description: 'Just another WordPress site', + action_label: 'Verify tagline', + }; + + it( 'renders a text input with name blogdescription', () => { + render( ); + + const input = screen.getByRole( 'textbox' ); + expect( input ).toBeInTheDocument(); + expect( input ).toHaveAttribute( 'name', 'blogdescription' ); + } ); + + it( 'pre-fills the input with site_description', () => { + render( ); + + const input = screen.getByRole( 'textbox' ); + expect( input ).toHaveValue( 'Just another WordPress site' ); + } ); + + it( 'renders a placeholder when no description is set', () => { + render( + + ); + + const input = screen.getByRole( 'textbox' ); + expect( input ).toHaveValue( '' ); + expect( input ).toHaveAttribute( 'placeholder' ); + } ); + + it( 'renders task title', () => { + render( ); + + expect( screen.getByText( 'Set your tagline' ) ).toBeInTheDocument(); + } ); + + it( 'renders the description text', () => { + render( ); + + expect( + screen.getByText( /explain what this site is about/i ) + ).toBeInTheDocument(); + } ); + + it( 'allows typing in the input', () => { + render( ); + + const input = screen.getByRole( 'textbox' ); + fireEvent.change( input, { + target: { value: 'My awesome blog' }, + } ); + expect( input ).toHaveValue( 'My awesome blog' ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/TaskForms/__tests__/LocaleForm.test.js b/assets/src/components/OnboardingWizard/TaskForms/__tests__/LocaleForm.test.js new file mode 100644 index 0000000000..f8be3b59ae --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/__tests__/LocaleForm.test.js @@ -0,0 +1,64 @@ +/** + * Tests for LocaleForm Component + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import LocaleForm from '../LocaleForm'; + +// Mock apiFetch. +jest.mock( '@wordpress/api-fetch', () => + jest.fn( () => + Promise.resolve( [ + { value: '', label: 'English (United States)' }, + { value: 'fr_FR', label: 'French' }, + { value: 'de_DE', label: 'German' }, + ] ) + ) +); + +describe( 'LocaleForm', () => { + const defaultTask = { + task_id: 'select-locale', + title: 'Set the locale', + action_label: 'Set the locale', + }; + + it( 'renders task title', async () => { + render( ); + + await waitFor( () => { + expect( screen.getByText( 'Set the locale' ) ).toBeInTheDocument(); + } ); + } ); + + it( 'renders the description text', async () => { + render( ); + + await waitFor( () => { + expect( + screen.getByText( /your locale determines/i ) + ).toBeInTheDocument(); + } ); + } ); + + it( 'renders a select with name WPLANG', async () => { + const { container } = render( ); + + await waitFor( () => { + const select = container.querySelector( 'select[name="WPLANG"]' ); + expect( select ).toBeInTheDocument(); + } ); + } ); + + it( 'loads locale options from API', async () => { + render( ); + + await waitFor( () => { + expect( + screen.getByText( 'English (United States)' ) + ).toBeInTheDocument(); + expect( screen.getByText( 'French' ) ).toBeInTheDocument(); + expect( screen.getByText( 'German' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/TaskForms/__tests__/SiteIconForm.test.js b/assets/src/components/OnboardingWizard/TaskForms/__tests__/SiteIconForm.test.js new file mode 100644 index 0000000000..27d0438126 --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/__tests__/SiteIconForm.test.js @@ -0,0 +1,56 @@ +/** + * Tests for SiteIconForm Component + */ + +import { render, screen } from '@testing-library/react'; +import SiteIconForm from '../SiteIconForm'; + +describe( 'SiteIconForm', () => { + const defaultTask = { + task_id: 'core-siteicon', + title: 'Set site icon', + action_label: 'Set site icon', + }; + + it( 'renders task title', () => { + render( ); + + expect( screen.getByText( 'Set site icon' ) ).toBeInTheDocument(); + } ); + + it( 'renders the description text', () => { + render( ); + + expect( screen.getByText( /upload an image/i ) ).toBeInTheDocument(); + } ); + + it( 'renders a file input', () => { + const { container } = render( ); + + const fileInput = container.querySelector( 'input[type="file"]' ); + expect( fileInput ).toBeInTheDocument(); + expect( fileInput ).toHaveAttribute( 'accept' ); + } ); + + it( 'renders a hidden post_id input', () => { + const { container } = render( ); + + const hiddenInput = container.querySelector( 'input[name="post_id"]' ); + expect( hiddenInput ).toBeInTheDocument(); + expect( hiddenInput ).toHaveAttribute( 'type', 'hidden' ); + } ); + + it( 'renders the drop zone', () => { + const { container } = render( ); + + const dropZone = container.querySelector( '.prpl-file-drop-zone' ); + expect( dropZone ).toBeInTheDocument(); + } ); + + it( 'renders the remove button (hidden initially)', () => { + render( ); + + const removeBtn = screen.getByText( /remove icon/i ); + expect( removeBtn ).toBeInTheDocument(); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/TaskForms/__tests__/TimezoneForm.test.js b/assets/src/components/OnboardingWizard/TaskForms/__tests__/TimezoneForm.test.js new file mode 100644 index 0000000000..49ac65d88c --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/__tests__/TimezoneForm.test.js @@ -0,0 +1,66 @@ +/** + * Tests for TimezoneForm Component + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import TimezoneForm from '../TimezoneForm'; + +// Mock apiFetch. +jest.mock( '@wordpress/api-fetch', () => + jest.fn( () => + Promise.resolve( [ + { value: 'America/New_York', label: 'New York' }, + { value: 'Europe/London', label: 'London' }, + { value: 'Asia/Tokyo', label: 'Tokyo' }, + ] ) + ) +); + +describe( 'TimezoneForm', () => { + const defaultTask = { + task_id: 'select-timezone', + title: 'Set the timezone', + action_label: 'Set the timezone', + }; + + it( 'renders task title', async () => { + render( ); + + await waitFor( () => { + expect( + screen.getByText( 'Set the timezone' ) + ).toBeInTheDocument(); + } ); + } ); + + it( 'renders the description text', async () => { + render( ); + + await waitFor( () => { + expect( + screen.getByText( /setting your timezone ensures/i ) + ).toBeInTheDocument(); + } ); + } ); + + it( 'renders a select with name timezone_string', async () => { + const { container } = render( ); + + await waitFor( () => { + const select = container.querySelector( + 'select[name="timezone_string"]' + ); + expect( select ).toBeInTheDocument(); + } ); + } ); + + it( 'loads timezone options from API', async () => { + render( ); + + await waitFor( () => { + expect( screen.getByText( 'New York' ) ).toBeInTheDocument(); + expect( screen.getByText( 'London' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Tokyo' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/TaskForms/index.js b/assets/src/components/OnboardingWizard/TaskForms/index.js new file mode 100644 index 0000000000..050a850cb2 --- /dev/null +++ b/assets/src/components/OnboardingWizard/TaskForms/index.js @@ -0,0 +1,21 @@ +/** + * Task Forms Registry + * + * Maps task IDs to their React form components. + * Used by OnboardTask and FirstTaskStep to render native React forms + * instead of PHP-generated HTML via dangerouslySetInnerHTML. + */ + +import BlogDescriptionForm from './BlogDescriptionForm'; +import SiteIconForm from './SiteIconForm'; +import LocaleForm from './LocaleForm'; +import TimezoneForm from './TimezoneForm'; + +const TASK_FORMS = { + 'core-blogdescription': BlogDescriptionForm, + 'core-siteicon': SiteIconForm, + 'select-locale': LocaleForm, + 'select-timezone': TimezoneForm, +}; + +export default TASK_FORMS; diff --git a/assets/src/components/OnboardingWizard/__tests__/OnboardingNavigation.test.js b/assets/src/components/OnboardingWizard/__tests__/OnboardingNavigation.test.js new file mode 100644 index 0000000000..8813ae0380 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingNavigation.test.js @@ -0,0 +1,321 @@ +/** + * Tests for OnboardingNavigation Component + */ + +import { render, screen } from '@testing-library/react'; +import OnboardingNavigation from '../OnboardingNavigation'; + +describe( 'OnboardingNavigation', () => { + const mockSteps = [ + { id: 'step-1', title: 'Welcome' }, + { id: 'step-2', title: 'Setup' }, + { id: 'step-3', title: 'Finish' }, + ]; + + const mockOnStepClick = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-onboarding-navigation' ) + ).toBeInTheDocument(); + } ); + + it( 'renders step list', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-step-list' ) + ).toBeInTheDocument(); + } ); + + it( 'renders all step items', () => { + const { container } = render( + + ); + + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items ).toHaveLength( 3 ); + } ); + + it( 'renders step titles', () => { + render( + + ); + + // Titles appear in both mobile label and step list, use getAllByText + expect( screen.getAllByText( 'Welcome' ).length ).toBeGreaterThan( + 0 + ); + expect( screen.getAllByText( 'Setup' ).length ).toBeGreaterThan( + 0 + ); + expect( screen.getAllByText( 'Finish' ).length ).toBeGreaterThan( + 0 + ); + } ); + } ); + + describe( 'step numbers', () => { + it( 'shows step numbers for upcoming steps', () => { + render( + + ); + + expect( screen.getByText( '1' ) ).toBeInTheDocument(); + expect( screen.getByText( '2' ) ).toBeInTheDocument(); + expect( screen.getByText( '3' ) ).toBeInTheDocument(); + } ); + + it( 'shows checkmark for completed steps', () => { + render( + + ); + + // Steps 1 and 2 (indices 0, 1) are completed + const stepIcons = screen.getAllByText( '✓' ); + expect( stepIcons ).toHaveLength( 2 ); + } ); + } ); + + describe( 'active step', () => { + it( 'marks current step as active', () => { + const { container } = render( + + ); + + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items[ 1 ] ).toHaveClass( 'prpl-active' ); + } ); + + it( 'marks completed steps', () => { + const { container } = render( + + ); + + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items[ 0 ] ).toHaveClass( 'prpl-completed' ); + expect( items[ 1 ] ).toHaveClass( 'prpl-completed' ); + expect( items[ 2 ] ).not.toHaveClass( 'prpl-completed' ); + } ); + } ); + + describe( 'mobile step label', () => { + it( 'shows current step title in mobile label', () => { + const { container } = render( + + ); + + const mobileLabel = container.querySelector( + '#prpl-onboarding-mobile-step-label' + ); + expect( mobileLabel ).toHaveTextContent( 'Setup' ); + } ); + } ); + + describe( 'step rendering', () => { + it( 'renders steps as list items (display-only, not clickable)', () => { + const { container } = render( + + ); + + // The component renders
  • items, not buttons + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items ).toHaveLength( 3 ); + } ); + + it( 'does not render clickable buttons', () => { + const { container } = render( + + ); + + const buttons = container.querySelectorAll( + '.prpl-nav-step-button' + ); + expect( buttons ).toHaveLength( 0 ); + } ); + } ); + + describe( 'logo', () => { + it( 'renders logo container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-onboarding-logo' ) + ).toBeInTheDocument(); + } ); + + it( 'renders logo HTML', () => { + const { container } = render( + + ); + + const logoContainer = container.querySelector( + '.prpl-onboarding-logo' + ); + expect( logoContainer.querySelector( 'img' ) ).toBeInTheDocument(); + } ); + + it( 'handles empty logoHtml', () => { + const { container } = render( + + ); + + const logoContainer = container.querySelector( + '.prpl-onboarding-logo' + ); + expect( logoContainer ).toBeInTheDocument(); + expect( logoContainer.innerHTML ).toBe( '' ); + } ); + } ); + + describe( 'data attributes', () => { + it( 'sets data-step attribute on items', () => { + const { container } = render( + + ); + + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items[ 0 ] ).toHaveAttribute( 'data-step', '0' ); + expect( items[ 1 ] ).toHaveAttribute( 'data-step', '1' ); + expect( items[ 2 ] ).toHaveAttribute( 'data-step', '2' ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles empty steps array', () => { + const { container } = render( + + ); + + const items = container.querySelectorAll( '.prpl-nav-step-item' ); + expect( items ).toHaveLength( 0 ); + } ); + + it( 'handles single step', () => { + render( + + ); + + // Title appears in both mobile label and step list + expect( screen.getAllByText( 'Only Step' ).length ).toBeGreaterThan( + 0 + ); + } ); + + it( 'handles step title with special characters', () => { + const specialSteps = [ + { id: 'special', title: "Step's & More" }, + ]; + + render( + + ); + + // Title appears in both mobile label and step list + expect( + screen.getAllByText( "Step's & More" ).length + ).toBeGreaterThan( 0 ); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/__tests__/OnboardingStep.test.js b/assets/src/components/OnboardingWizard/__tests__/OnboardingStep.test.js new file mode 100644 index 0000000000..e580107023 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingStep.test.js @@ -0,0 +1,364 @@ +/** + * Tests for OnboardingStep Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import OnboardingStep from '../OnboardingStep'; + +describe( 'OnboardingStep', () => { + const mockWizardState = { + data: { test: 'value' }, + }; + + const mockOnNext = jest.fn(); + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + +

    Step content

    +
    + ); + + expect( + container.querySelector( '.onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders children content', () => { + render( + +

    Test content inside step

    +
    + ); + + expect( + screen.getByText( 'Test content inside step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders tour footer', () => { + const { container } = render( + +

    Content

    +
    + ); + + expect( + container.querySelector( '.tour-footer' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'next button', () => { + it( 'renders next button', () => { + render( + +

    Content

    +
    + ); + + expect( + screen.getByRole( 'button', { name: /next/i } ) + ).toBeInTheDocument(); + } ); + + it( 'calls onNext when clicked', () => { + render( + +

    Content

    +
    + ); + + const nextButton = screen.getByRole( 'button', { name: /next/i } ); + fireEvent.click( nextButton ); + + expect( mockOnNext ).toHaveBeenCalled(); + } ); + + it( 'uses custom button text', () => { + render( + +

    Content

    +
    + ); + + expect( + screen.getByRole( 'button', { name: 'Continue' } ) + ).toBeInTheDocument(); + } ); + + it( 'uses custom button class', () => { + const { container } = render( + +

    Content

    +
    + ); + + const nextButton = container.querySelector( '.prpl-tour-next' ); + expect( nextButton ).toHaveClass( 'prpl-btn-success' ); + } ); + + it( 'defaults to primary button class', () => { + const { container } = render( + +

    Content

    +
    + ); + + const nextButton = container.querySelector( '.prpl-tour-next' ); + expect( nextButton ).toHaveClass( 'prpl-btn-primary' ); + } ); + } ); + + describe( 'back button', () => { + it( 'does not render a back button', () => { + render( + +

    Content

    +
    + ); + + expect( + screen.queryByRole( 'button', { name: /back/i } ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'canProceed', () => { + it( 'enables next button when canProceed returns true', () => { + render( + true } + > +

    Content

    +
    + ); + + const nextButton = screen.getByRole( 'button', { name: /next/i } ); + expect( nextButton ).not.toBeDisabled(); + } ); + + it( 'adds disabled class when canProceed returns false', () => { + const { container } = render( + false } + > +

    Content

    +
    + ); + + const nextButton = container.querySelector( '.prpl-tour-next' ); + expect( nextButton ).toHaveClass( 'prpl-btn-disabled' ); + } ); + + it( 'passes wizardState to canProceed', () => { + const mockCanProceed = jest.fn().mockReturnValue( true ); + + render( + +

    Content

    +
    + ); + + expect( mockCanProceed ).toHaveBeenCalledWith( mockWizardState ); + } ); + + it( 'does not call onNext when button is disabled', () => { + render( + false } + > +

    Content

    +
    + ); + + const nextButton = screen.getByRole( 'button', { name: /next/i } ); + fireEvent.click( nextButton ); + + expect( mockOnNext ).not.toHaveBeenCalled(); + } ); + + it( 'defaults to always allowing proceed', () => { + render( + +

    Content

    +
    + ); + + const nextButton = screen.getByRole( 'button', { name: /next/i } ); + expect( nextButton ).not.toBeDisabled(); + } ); + } ); + + describe( 'disabled button behavior', () => { + it( 'adds disabled class when canProceed is false', () => { + const { container } = render( + false } + > +

    Content

    +
    + ); + + const nextButton = container.querySelector( '.prpl-tour-next' ); + expect( nextButton ).toHaveClass( 'prpl-btn-disabled' ); + } ); + + it( 'removes disabled class when canProceed becomes true', () => { + const { rerender, container } = render( + false } + > +

    Content

    +
    + ); + + // Re-render with canProceed returning true + rerender( + true } + > +

    Content

    +
    + ); + + const nextButton = container.querySelector( '.prpl-tour-next' ); + expect( nextButton ).not.toHaveClass( 'prpl-btn-disabled' ); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has prpl-tour-next-wrapper class', () => { + const { container } = render( + +

    Content

    +
    + ); + + expect( + container.querySelector( '.prpl-tour-next-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'has prpl-btn class on next button', () => { + const { container } = render( + +

    Content

    +
    + ); + + const buttons = container.querySelectorAll( '.prpl-btn' ); + expect( buttons ).toHaveLength( 1 ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles empty children', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'handles multiple children', () => { + render( + +

    Title

    +

    Paragraph 1

    +

    Paragraph 2

    +
    + ); + + expect( screen.getByText( 'Title' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Paragraph 1' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Paragraph 2' ) ).toBeInTheDocument(); + } ); + + it( 'handles complex button text', () => { + render( + + Get Started + + + } + > +

    Content

    +
    + ); + + expect( screen.getByText( 'Get Started' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/__tests__/OnboardingWizard.test.js b/assets/src/components/OnboardingWizard/__tests__/OnboardingWizard.test.js new file mode 100644 index 0000000000..ed5eb75314 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingWizard.test.js @@ -0,0 +1,677 @@ +/** + * Tests for OnboardingWizard Component + */ + +/* global Element */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { createRef } from '@wordpress/element'; + +// Mock Element.prototype.matches to handle :popover-open pseudo-class +const originalMatches = Element.prototype.matches; +Element.prototype.matches = function ( selector ) { + if ( selector === ':popover-open' ) { + return false; + } + return originalMatches.call( this, selector ); +}; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +// Mock task registry and tasks +jest.mock( '../../../services/taskRegistry', () => ( { + evaluateOnboardingTasks: jest.fn().mockResolvedValue( undefined ), +} ) ); +jest.mock( '../../../tasks', () => {} ); + +// Mock dashboardStore +jest.mock( '../../../stores/dashboardStore', () => ( { + useDashboardStore: jest.fn( () => false ), +} ) ); + +// Mock hooks +jest.mock( '../../../hooks/useOnboardingWizard', () => ( { + useOnboardingWizard: jest.fn( () => ( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: { id: 'onboarding-step-welcome' }, + } ) ), +} ) ); + +jest.mock( '../../../hooks/useOnboardingProgress', () => ( { + useOnboardingProgress: jest.fn( () => ( { + saveProgress: jest.fn().mockResolvedValue( {} ), + } ) ), +} ) ); + +// Mock step components +jest.mock( '../steps/WelcomeStep', () => () => ( +
    WelcomeStep
    +) ); + +jest.mock( '../steps/WhatsWhatStep', () => () => ( +
    WhatsWhatStep
    +) ); + +jest.mock( '../steps/FirstTaskStep', () => () => ( +
    FirstTaskStep
    +) ); + +jest.mock( '../steps/BadgesStep', () => () => ( +
    BadgesStep
    +) ); + +jest.mock( '../steps/EmailFrequencyStep', () => () => ( +
    EmailFrequencyStep
    +) ); + +jest.mock( '../steps/SettingsStep', () => () => ( +
    SettingsStep
    +) ); + +jest.mock( '../steps/MoreTasksStep', () => () => ( +
    MoreTasksStep
    +) ); + +jest.mock( '../OnboardingNavigation', () => () => ( +
    OnboardingNavigation
    +) ); + +jest.mock( '../QuitConfirmation', () => ( props ) => ( +
    + QuitConfirmation + + +
    +) ); + +// Import after mocks +import OnboardingWizard from '../index'; +import apiFetch from '@wordpress/api-fetch'; +import { useDashboardStore } from '../../../stores/dashboardStore'; +import { useOnboardingWizard } from '../../../hooks/useOnboardingWizard'; +import { useOnboardingProgress } from '../../../hooks/useOnboardingProgress'; + +describe( 'OnboardingWizard', () => { + const mockWizardConfig = { + enabled: true, + steps: [ + { id: 'onboarding-step-welcome' }, + { id: 'onboarding-step-whats-what' }, + ], + savedProgress: null, + ajaxUrl: '/wp-admin/admin-ajax.php', + nonce: 'test-nonce', + logoHtml: '', + }; + + const defaultConfig = { + onboardingWizard: mockWizardConfig, + }; + + beforeEach( () => { + jest.clearAllMocks(); + apiFetch.mockResolvedValue( mockWizardConfig ); + + useDashboardStore.mockImplementation( ( selector ) => { + const state = { + shouldAutoStartWizard: false, + setShouldAutoStartWizard: jest.fn(), + }; + return selector( state ); + } ); + + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: { id: 'onboarding-step-welcome' }, + } ); + + useOnboardingProgress.mockReturnValue( { + saveProgress: jest.fn().mockResolvedValue( {} ), + } ); + } ); + + describe( 'loading state', () => { + it( 'returns null while loading config', () => { + apiFetch.mockImplementation( + () => new Promise( () => {} ) // Never resolves + ); + + const { container } = render( + + ); + + // Should render nothing during loading + expect( container.firstChild ).toBeNull(); + } ); + } ); + + describe( 'error handling', () => { + it( 'uses fallback config on API error', async () => { + apiFetch.mockRejectedValue( new Error( 'API Error' ) ); + + await act( async () => { + render( ); + } ); + + // Should still render with fallback config + expect( screen.getByTestId( 'welcome-step' ) ).toBeInTheDocument(); + } ); + + it( 'returns null on error with no fallback', async () => { + apiFetch.mockRejectedValue( new Error( 'API Error' ) ); + + const configWithoutFallback = {}; + + let container; + await act( async () => { + const result = render( + + ); + container = result.container; + } ); + + expect( container.firstChild ).toBeNull(); + } ); + } ); + + describe( 'disabled wizard', () => { + it( 'returns null when wizard is not enabled', async () => { + const disabledConfig = { + onboardingWizard: { + ...mockWizardConfig, + enabled: false, + }, + }; + + apiFetch.mockResolvedValue( { + ...mockWizardConfig, + enabled: false, + } ); + + let container; + await act( async () => { + const result = render( + + ); + container = result.container; + } ); + + expect( container.firstChild ).toBeNull(); + } ); + } ); + + describe( 'enabled wizard', () => { + it( 'renders popover element', async () => { + await act( async () => { + render( ); + } ); + + expect( + document.getElementById( 'prpl-popover-onboarding' ) + ).toBeInTheDocument(); + } ); + + it( 'renders with dialog role', async () => { + await act( async () => { + render( ); + } ); + + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + it( 'has aria-modal attribute', async () => { + await act( async () => { + render( ); + } ); + + expect( screen.getByRole( 'dialog' ) ).toHaveAttribute( + 'aria-modal', + 'true' + ); + } ); + + it( 'renders current step component', async () => { + await act( async () => { + render( ); + } ); + + expect( screen.getByTestId( 'welcome-step' ) ).toBeInTheDocument(); + } ); + + it( 'renders navigation component', async () => { + await act( async () => { + render( ); + } ); + + expect( + screen.getByTestId( 'onboarding-navigation' ) + ).toBeInTheDocument(); + } ); + + it( 'renders close button', async () => { + await act( async () => { + render( ); + } ); + + expect( + screen.getByRole( 'button', { name: 'Close' } ) + ).toBeInTheDocument(); + } ); + + it( 'has correct popover class', async () => { + await act( async () => { + render( ); + } ); + + const popover = document.getElementById( + 'prpl-popover-onboarding' + ); + expect( popover ).toHaveClass( 'prpl-popover-onboarding' ); + } ); + + it( 'has popover attribute', async () => { + await act( async () => { + render( ); + } ); + + const popover = document.getElementById( + 'prpl-popover-onboarding' + ); + expect( popover ).toHaveAttribute( 'popover', 'manual' ); + } ); + + it( 'has data-prpl-step attribute', async () => { + await act( async () => { + render( ); + } ); + + const popover = document.getElementById( + 'prpl-popover-onboarding' + ); + expect( popover ).toHaveAttribute( 'data-prpl-step', '0' ); + } ); + } ); + + describe( 'step rendering', () => { + it( 'renders WelcomeStep for welcome step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: { id: 'onboarding-step-welcome' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( screen.getByTestId( 'welcome-step' ) ).toBeInTheDocument(); + } ); + + it( 'renders WhatsWhatStep for whats-what step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 1, + currentStepData: { id: 'onboarding-step-whats-what' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.getByTestId( 'whats-what-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders FirstTaskStep for first-task step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 2, + currentStepData: { id: 'onboarding-step-first-task' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.getByTestId( 'first-task-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders BadgesStep for badges step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 3, + currentStepData: { id: 'onboarding-step-badges' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( screen.getByTestId( 'badges-step' ) ).toBeInTheDocument(); + } ); + + it( 'renders EmailFrequencyStep for email-frequency step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 4, + currentStepData: { id: 'onboarding-step-email-frequency' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.getByTestId( 'email-frequency-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders SettingsStep for settings step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 5, + currentStepData: { id: 'onboarding-step-settings' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( screen.getByTestId( 'settings-step' ) ).toBeInTheDocument(); + } ); + + it( 'renders MoreTasksStep for more-tasks step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 6, + currentStepData: { id: 'onboarding-step-more-tasks' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.getByTestId( 'more-tasks-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders null for unknown step', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: { id: 'unknown-step' }, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.queryByTestId( 'welcome-step' ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders null when no currentStepData', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: false }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: null, + } ); + + await act( async () => { + render( ); + } ); + + expect( + screen.queryByTestId( 'welcome-step' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'quit confirmation', () => { + it( 'shows quit confirmation when close button clicked', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = screen.getByRole( 'button', { name: 'Close' } ); + fireEvent.click( closeBtn ); + + expect( + screen.getByTestId( 'quit-confirmation' ) + ).toBeInTheDocument(); + } ); + + it( 'hides navigation when quit confirmation shown', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = screen.getByRole( 'button', { name: 'Close' } ); + fireEvent.click( closeBtn ); + + expect( + screen.queryByTestId( 'onboarding-navigation' ) + ).not.toBeInTheDocument(); + } ); + + it( 'hides close button when quit confirmation shown', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = screen.getByRole( 'button', { name: 'Close' } ); + fireEvent.click( closeBtn ); + + expect( + screen.queryByRole( 'button', { name: 'Close' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'returns to step when cancel quit', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = screen.getByRole( 'button', { name: 'Close' } ); + fireEvent.click( closeBtn ); + + const cancelBtn = screen.getByTestId( 'quit-cancel-btn' ); + fireEvent.click( cancelBtn ); + + expect( screen.getByTestId( 'welcome-step' ) ).toBeInTheDocument(); + } ); + + it( 'saves progress when quit confirmed', async () => { + const mockSaveProgress = jest.fn().mockResolvedValue( {} ); + useOnboardingProgress.mockReturnValue( { + saveProgress: mockSaveProgress, + } ); + + await act( async () => { + render( ); + } ); + + const closeBtn = screen.getByRole( 'button', { name: 'Close' } ); + fireEvent.click( closeBtn ); + + const confirmBtn = screen.getByTestId( 'quit-confirm-btn' ); + await act( async () => { + fireEvent.click( confirmBtn ); + } ); + + expect( mockSaveProgress ).toHaveBeenCalled(); + } ); + } ); + + describe( 'imperative handle', () => { + it( 'exposes startOnboarding method via ref', async () => { + const ref = createRef(); + + await act( async () => { + render( + + ); + } ); + + expect( ref.current ).toHaveProperty( 'startOnboarding' ); + expect( typeof ref.current.startOnboarding ).toBe( 'function' ); + } ); + } ); + + describe( 'API fetch', () => { + it( 'fetches config from REST API on mount', async () => { + await act( async () => { + render( ); + } ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/progress-planner/v1/onboarding-wizard/config', + } ); + } ); + } ); + + describe( 'finished wizard', () => { + it( 'still renders when wizard is finished', async () => { + useOnboardingWizard.mockReturnValue( { + wizardState: { data: { finished: true }, steps: {} }, + updateState: jest.fn(), + nextStep: jest.fn(), + prevStep: jest.fn(), + goToStep: jest.fn(), + currentStep: 0, + currentStepData: { id: 'onboarding-step-welcome' }, + } ); + + await act( async () => { + render( ); + } ); + + // Wizard should still be in DOM (visibility controlled elsewhere) + expect( + document.getElementById( 'prpl-popover-onboarding' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'layout structure', () => { + it( 'renders onboarding layout container', async () => { + const { container } = await act( async () => { + return render( ); + } ); + + expect( + container.querySelector( '.prpl-onboarding-layout' ) + ).toBeInTheDocument(); + } ); + + it( 'renders onboarding content container', async () => { + const { container } = await act( async () => { + return render( ); + } ); + + expect( + container.querySelector( '.prpl-onboarding-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders tour content wrapper', async () => { + const { container } = await act( async () => { + return render( ); + } ); + + expect( + container.querySelector( '.tour-content-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'close button has correct ID', async () => { + await act( async () => { + render( ); + } ); + + expect( + document.getElementById( 'prpl-tour-close-btn' ) + ).toBeInTheDocument(); + } ); + + it( 'close button has correct class', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = document.getElementById( 'prpl-tour-close-btn' ); + expect( closeBtn ).toHaveClass( 'prpl-popover-close' ); + } ); + + it( 'close button has dashicon', async () => { + await act( async () => { + render( ); + } ); + + const closeBtn = document.getElementById( 'prpl-tour-close-btn' ); + expect( + closeBtn.querySelector( '.dashicons-no-alt' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/__tests__/QuitConfirmation.test.js b/assets/src/components/OnboardingWizard/__tests__/QuitConfirmation.test.js new file mode 100644 index 0000000000..9c2905df05 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/QuitConfirmation.test.js @@ -0,0 +1,425 @@ +/** + * Tests for QuitConfirmation Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import QuitConfirmation from '../QuitConfirmation'; + +describe( 'QuitConfirmation', () => { + const mockOnConfirm = jest.fn(); + const mockOnCancel = jest.fn(); + const mockConfig = { + l10n: { + brandingName: 'Progress Planner', + }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders without crashing', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-quit-confirmation' ) + ).toBeInTheDocument(); + } ); + + it( 'renders title', () => { + render( + + ); + + expect( + screen.getByText( 'Are you sure you want to quit?' ) + ).toBeInTheDocument(); + } ); + + it( 'renders description with branding name', () => { + render( + + ); + + expect( + screen.getByText( /Progress Planner/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders both action buttons', () => { + render( + + ); + + expect( + screen.getByRole( 'button', { name: /yes, quit/i } ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: /no, let's finish/i } ) + ).toBeInTheDocument(); + } ); + + it( 'renders error icon', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-error-icon' ) + ).toBeInTheDocument(); + } ); + + it( 'renders SVG in error icon', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-error-icon svg' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'button actions', () => { + it( 'calls onConfirm when quit button clicked', () => { + render( + + ); + + const quitButton = screen.getByRole( 'button', { + name: /yes, quit/i, + } ); + fireEvent.click( quitButton ); + + expect( mockOnConfirm ).toHaveBeenCalled(); + } ); + + it( 'calls onCancel when cancel button clicked', () => { + render( + + ); + + const cancelButton = screen.getByRole( 'button', { + name: /no, let's finish/i, + } ); + fireEvent.click( cancelButton ); + + expect( mockOnCancel ).toHaveBeenCalled(); + } ); + + it( 'prevents default on button clicks', () => { + render( + + ); + + const quitButton = screen.getByRole( 'button', { + name: /yes, quit/i, + } ); + const cancelButton = screen.getByRole( 'button', { + name: /no, let's finish/i, + } ); + + // Events should be handled without throwing + fireEvent.click( quitButton ); + fireEvent.click( cancelButton ); + + expect( mockOnConfirm ).toHaveBeenCalled(); + expect( mockOnCancel ).toHaveBeenCalled(); + } ); + } ); + + describe( 'branding name', () => { + it( 'uses custom branding name from config', () => { + const customConfig = { + l10n: { + brandingName: 'My Custom Brand', + }, + }; + + render( + + ); + + expect( screen.getByText( /My Custom Brand/ ) ).toBeInTheDocument(); + } ); + + it( 'uses default branding name when not in config', () => { + render( + + ); + + expect( + screen.getByText( /Progress Planner/ ) + ).toBeInTheDocument(); + } ); + + it( 'uses default branding name when config is undefined', () => { + render( + + ); + + expect( + screen.getByText( /Progress Planner/ ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'CSS classes', () => { + it( 'has error box class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-error-box' ) + ).toBeInTheDocument(); + } ); + + it( 'has quit actions wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-quit-actions' ) + ).toBeInTheDocument(); + } ); + + it( 'quit button has correct class', () => { + const { container } = render( + + ); + + expect( container.querySelector( '#prpl-quit-yes' ) ).toHaveClass( + 'prpl-quit-link' + ); + } ); + + it( 'cancel button has primary class', () => { + const { container } = render( + + ); + + expect( container.querySelector( '#prpl-quit-no' ) ).toHaveClass( + 'prpl-quit-link-primary' + ); + } ); + + it( 'has columns wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'layout', () => { + it( 'renders two columns', () => { + const { container } = render( + + ); + + const columns = container.querySelectorAll( '.prpl-column' ); + expect( columns ).toHaveLength( 2 ); + } ); + + it( 'second column is hidden on mobile', () => { + const { container } = render( + + ); + + const columns = container.querySelectorAll( '.prpl-column' ); + expect( columns[ 1 ] ).toHaveClass( 'prpl-hide-on-mobile' ); + } ); + + it( 'has graphic placeholder in second column', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-quit-confirmation-graphic' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'accessibility', () => { + it( 'has title with id for aria-labelledby', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-quit-confirmation-title' ) + ).toBeInTheDocument(); + } ); + + it( 'quit button has type button', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-quit-yes' ) + ).toHaveAttribute( 'type', 'button' ); + } ); + + it( 'cancel button has type button', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '#prpl-quit-no' ) + ).toHaveAttribute( 'type', 'button' ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'handles onConfirm not being a function', () => { + render( + + ); + + const quitButton = screen.getByRole( 'button', { + name: /yes, quit/i, + } ); + + // Should not throw + expect( () => fireEvent.click( quitButton ) ).not.toThrow(); + } ); + + it( 'handles onCancel not being a function', () => { + render( + + ); + + const cancelButton = screen.getByRole( 'button', { + name: /no, let's finish/i, + } ); + + // Should not throw + expect( () => fireEvent.click( cancelButton ) ).not.toThrow(); + } ); + + it( 'handles empty l10n object', () => { + render( + + ); + + expect( + screen.getByText( /Progress Planner/ ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/index.js b/assets/src/components/OnboardingWizard/index.js new file mode 100644 index 0000000000..e87e0f8bd0 --- /dev/null +++ b/assets/src/components/OnboardingWizard/index.js @@ -0,0 +1,432 @@ +/** + * OnboardingWizard Component + * + * Main onboarding wizard component that manages the multi-step wizard. + * + * @package + */ + +import { + useState, + useEffect, + useImperativeHandle, + forwardRef, + useRef, + useCallback, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { useDashboardStore } from '../../stores/dashboardStore'; +import { useOnboardingWizard } from '../../hooks/useOnboardingWizard'; +import { useOnboardingProgress } from '../../hooks/useOnboardingProgress'; +import { evaluateOnboardingTasks } from '../../services/taskRegistry'; + +// Import tasks so providers are registered before evaluateOnboardingTasks runs. +import '../../tasks'; +import WelcomeStep from './steps/WelcomeStep'; +import WhatsWhatStep from './steps/WhatsWhatStep'; +import FirstTaskStep from './steps/FirstTaskStep'; +import BadgesStep from './steps/BadgesStep'; +import EmailFrequencyStep from './steps/EmailFrequencyStep'; +import SettingsStep from './steps/SettingsStep'; +import MoreTasksStep from './steps/MoreTasksStep'; +import OnboardingNavigation from './OnboardingNavigation'; +import QuitConfirmation from './QuitConfirmation'; + +/** + * OnboardingWizard component. + * + * @param {Object} props - Component props. + * @param {Object} props.config - Wizard configuration from PHP. + * @param {Object} ref - Ref to expose startOnboarding method. + * @return {JSX.Element|null} The wizard component or null if not enabled. + */ +const OnboardingWizard = forwardRef( function OnboardingWizard( + { config }, + ref +) { + // State for wizard config fetched from REST API. + const [ wizardConfig, setWizardConfig ] = useState( null ); + const [ isLoadingConfig, setIsLoadingConfig ] = useState( true ); + const [ configError, setConfigError ] = useState( null ); + + // Fallback to config.onboardingWizard if available (for backwards compatibility). + const fallbackWizard = config.onboardingWizard; + + // Fetch wizard config from REST API on mount. + useEffect( () => { + const fetchWizardConfig = async () => { + try { + setIsLoadingConfig( true ); + setConfigError( null ); + + // Ensure onboarding tasks exist before fetching config. + // This creates tasks in the database so PHP can find them. + await evaluateOnboardingTasks(); + + const response = await apiFetch( { + path: '/progress-planner/v1/onboarding-wizard/config', + } ); + + setWizardConfig( response ); + } catch ( error ) { + // Fallback to config.onboardingWizard if available. + if ( fallbackWizard ) { + setWizardConfig( fallbackWizard ); + } else { + setConfigError( error ); + } + } finally { + setIsLoadingConfig( false ); + } + }; + + fetchWizardConfig(); + }, [ fallbackWizard ] ); + + // Use fetched config or fallback. + const onboardingWizard = wizardConfig || fallbackWizard; + + // Initialize hooks before early return to comply with React hooks rules. + const { steps, savedProgress, ajaxUrl, nonce } = onboardingWizard || {}; + + const progressHooks = useOnboardingProgress( { + ajaxUrl: ajaxUrl || '', + nonce: nonce || '', + } ); + const { + wizardState, + updateState, + nextStep, + prevStep, + currentStep, + currentStepData, + } = useOnboardingWizard( onboardingWizard || {}, progressHooks ); + + const [ showQuitConfirmation, setShowQuitConfirmation ] = useState( false ); + const [ isOpen, setIsOpen ] = useState( false ); + const popoverRef = useRef( null ); + const hasManuallyQuitRef = useRef( false ); // Track if user manually quit to prevent auto-restart + const shouldAutoStartWizard = useDashboardStore( + ( state ) => state.shouldAutoStartWizard + ); + const setShouldAutoStartWizard = useDashboardStore( + ( state ) => state.setShouldAutoStartWizard + ); + + // Expose startOnboarding method via ref (like develop's window.prplOnboardWizard.startOnboarding). + useImperativeHandle( ref, () => ( { + startOnboarding() { + if ( + ! wizardState.data.finished && + onboardingWizard?.enabled && + popoverRef.current + ) { + // Show popover using native API (like develop) + if ( typeof popoverRef.current.showPopover === 'function' ) { + popoverRef.current.showPopover(); + } + setIsOpen( true ); + + // Move focus to popover for keyboard accessibility + setTimeout( () => { + if ( popoverRef.current ) { + popoverRef.current.focus(); + } + }, 0 ); + } + }, + } ) ); + + /** + * Ref callback to detect when popover element is mounted. + * Checks Zustand store for auto-start flag and handles auto-start. + * Also sets up toggle event listener to sync isOpen state. + * + * @param {HTMLElement|null} element - The popover element or null when unmounted. + * @return {void} + */ + const popoverRefCallback = useCallback( + ( element ) => { + // Store ref for imperative handle + const previousElement = popoverRef.current; + popoverRef.current = element; + + // Clean up toggle listener from previous element if it changed + if ( previousElement && previousElement !== element ) { + const previousToggleHandler = + previousElement.__prplToggleHandler; + if ( previousToggleHandler ) { + previousElement.removeEventListener( + 'toggle', + previousToggleHandler + ); + delete previousElement.__prplToggleHandler; + } + } + + // Only proceed if element is mounted and wizard is enabled + if ( ! element ) { + return; + } + + // Set up toggle event listener to sync isOpen state with popover's actual state + if ( ! element.__prplToggleHandler ) { + /** + * Handle popover toggle event to sync isOpen state. + * + * @param {Event} event - Toggle event. + */ + const handleToggle = ( event ) => { + setIsOpen( event.newState === 'open' ); + }; + + element.addEventListener( 'toggle', handleToggle ); + element.__prplToggleHandler = handleToggle; + } + + if ( ! onboardingWizard?.enabled ) { + return; + } + + // Don't auto-start if wizard is already finished + if ( wizardState.data.finished ) { + return; + } + + // Don't auto-start if popover is already open + if ( element.matches( ':popover-open' ) ) { + setIsOpen( true ); + return; + } + + // Don't auto-start if user has manually quit (prevents re-opening after quit) + if ( hasManuallyQuitRef.current ) { + return; + } + + // Check if we should auto-start (only when there's NO saved progress, like develop branch) + const hasSavedProgress = + savedProgress && Object.keys( savedProgress ).length > 0; + + // Auto-start ONLY when there's NO saved progress (matches develop branch logic) + // Develop branch: if ( ! $get_saved_progress ) { startOnboarding(); } + // Conditions: + // 1. Zustand flag is set (privacy not accepted - fresh install) + // 2. There is NO saved progress (user hasn't quit before) + // 3. User hasn't manually quit in this session + if ( + shouldAutoStartWizard && + ! hasSavedProgress && + ! hasManuallyQuitRef.current + ) { + // Popover element is now in DOM, safe to show + if ( typeof element.showPopover === 'function' ) { + try { + element.showPopover(); + setIsOpen( true ); + + // Clear the Zustand flag after starting + if ( shouldAutoStartWizard ) { + setShouldAutoStartWizard( false ); + } + + // Move focus to popover for keyboard accessibility + setTimeout( () => { + if ( element ) { + element.focus(); + } + }, 0 ); + } catch ( error ) { + console.error( + '[OnboardingWizard] Ref callback: Error calling showPopover()', + error + ); + } + } + } + }, + [ + onboardingWizard?.enabled, + wizardState.data.finished, + savedProgress, + shouldAutoStartWizard, + setShouldAutoStartWizard, + ] + ); + + // Handle keyboard navigation (Escape key to close). + useEffect( () => { + if ( ! isOpen ) { + return; + } + + /** + * Handle Escape key press. + * + * @param {KeyboardEvent} event - Keyboard event. + */ + const handleKeyDown = ( event ) => { + if ( event.key === 'Escape' && ! showQuitConfirmation ) { + setShowQuitConfirmation( true ); + } + }; + + document.addEventListener( 'keydown', handleKeyDown ); + + return () => { + document.removeEventListener( 'keydown', handleKeyDown ); + }; + }, [ isOpen, showQuitConfirmation ] ); + + /** + * Handle close button click. + */ + const handleClose = () => { + setShowQuitConfirmation( true ); + }; + + /** + * Handle quit confirmation. + * Matches develop branch's closeTour() behavior: hide popover first, then save progress. + */ + const handleQuit = () => { + // Mark that user manually quit to prevent auto-restart + hasManuallyQuitRef.current = true; + + // Hide quit confirmation UI + setShowQuitConfirmation( false ); + + // Hide popover first (like develop branch's closeTour) + const element = popoverRef.current; + if ( element && typeof element.hidePopover === 'function' ) { + element.hidePopover(); + } + setIsOpen( false ); + + // Save progress to server (like develop branch's saveProgressToServer) + progressHooks.saveProgress( wizardState ).catch( () => { + // Silently fail - progress save shouldn't block closing + } ); + }; + + /** + * Handle cancel quit. + */ + const handleCancelQuit = () => { + setShowQuitConfirmation( false ); + }; + + /** + * Render current step component or quit confirmation. + * + * @return {JSX.Element} Current step component or quit confirmation. + */ + const renderStep = () => { + // Show quit confirmation if requested + if ( showQuitConfirmation ) { + return ( + + ); + } + + // Otherwise show current step + if ( ! currentStepData ) { + return null; + } + + const handleBack = currentStep > 0 ? prevStep : null; + + const stepProps = { + wizardState, + updateState, + onNext: nextStep, + onBack: handleBack, + config: onboardingWizard, + stepData: currentStepData, + }; + + switch ( currentStepData.id ) { + case 'onboarding-step-welcome': + return ; + case 'onboarding-step-whats-what': + return ; + case 'onboarding-step-first-task': + return ; + case 'onboarding-step-badges': + return ; + case 'onboarding-step-email-frequency': + return ; + case 'onboarding-step-settings': + return ; + case 'onboarding-step-more-tasks': + return ; + default: + return null; + } + }; + + // Show loading state while fetching config. + if ( isLoadingConfig ) { + return null; // Don't render while loading. + } + + // Show error state if config failed to load and no fallback. + if ( configError && ! fallbackWizard ) { + return null; // Don't render on error. + } + + // Always render wizard (like develop's add_popover), but control visibility via isOpen. + // If wizard is not enabled, don't render at all. + if ( ! onboardingWizard?.enabled ) { + return null; + } + + return ( + <> + + + ); +} ); + +export default OnboardingWizard; diff --git a/assets/src/components/OnboardingWizard/steps/BadgesStep.js b/assets/src/components/OnboardingWizard/steps/BadgesStep.js new file mode 100644 index 0000000000..acaa38eed1 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/BadgesStep.js @@ -0,0 +1,115 @@ +/** + * BadgesStep Component + * + * Step explaining the badge system. + * + * @package + */ + +import { useEffect, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; +import Gauge from '../../Gauge'; +import Badge from '../../Badge'; + +/** + * BadgesStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} Badges step component. + */ +export default function BadgesStep( props ) { + const { wizardState, stepData } = props; + const badgeData = useMemo( () => stepData?.data || {}, [ stepData?.data ] ); + + // State for animated gauge value. + const initialValue = badgeData.currentValue || 0; + const [ gaugeValue, setGaugeValue ] = useState( initialValue ); + + // Increment badge points after first task completion (with delay for animation). + useEffect( () => { + if ( wizardState.data.firstTaskCompleted && badgeData.badgeId ) { + const timer = setTimeout( () => { + setGaugeValue( ( prev ) => prev + 1 ); + }, 1500 ); + + return () => clearTimeout( timer ); + } + }, [ wizardState.data.firstTaskCompleted, badgeData.badgeId ] ); + + return ( + true } + buttonText={ __( 'Got it', 'progress-planner' ) } + buttonClass="prpl-btn-secondary" + > +
    +
    +
    +
    +

    + { __( + 'Whoohoo, nice one! You just earned your first point!', + 'progress-planner' + ) } +

    +

    + { __( + 'Gather ten points this month to unlock your special badge.', + 'progress-planner' + ) } +

    +

    + { __( + "You're off to a great start!", + 'progress-planner' + ) } +

    +
    +
    +
    +
    + { badgeData.badgeId && badgeData.badgeName ? ( + + + + ) : ( + + { gaugeValue } + + ) } +

    + { __( 'Monthly badge', 'progress-planner' ) } +

    +
    +
    +
    +
    +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/EmailFrequencyStep.js b/assets/src/components/OnboardingWizard/steps/EmailFrequencyStep.js new file mode 100644 index 0000000000..c2cd7c37ca --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/EmailFrequencyStep.js @@ -0,0 +1,286 @@ +/** + * EmailFrequencyStep Component + * + * Step for configuring email frequency preferences. + * + * @package + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import OnboardingStep from '../OnboardingStep'; +import { CustomRadio } from '../../FormInputs'; + +/** + * EmailFrequencyStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} EmailFrequency step component. + */ +export default function EmailFrequencyStep( props ) { + const { wizardState, updateState, config, onNext } = props; + const { userFirstName = '', userEmail = '', site, timezoneOffset } = config; + + const [ emailFrequency, setEmailFrequency ] = useState( () => { + const saved = wizardState.data.emailFrequency; + return { + choice: saved?.choice || 'weekly', + name: saved?.name || userFirstName, + email: saved?.email || userEmail, + }; + } ); + + const [ isSubscribing, setIsSubscribing ] = useState( false ); + const [ subscriptionError, setSubscriptionError ] = useState( null ); + + // Update wizard state when email frequency changes. + useEffect( () => { + updateState( { + data: { + ...wizardState.data, + emailFrequency, + }, + } ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ emailFrequency ] ); + + /** + * Check if can proceed. + * + * @return {boolean} True if can proceed. + */ + const canProceed = () => { + // Disable button while subscribing. + if ( isSubscribing ) { + return false; + } + + 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; + }; + + /** + * Handle next button click. + * Subscribes user if they chose weekly emails, then proceeds to next step. + */ + const handleNext = async () => { + // If user chose "don't email", proceed immediately without API call. + if ( emailFrequency.choice === 'none' ) { + onNext(); + return; + } + + // If user chose "weekly", subscribe via REST API first. + if ( emailFrequency.choice === 'weekly' ) { + setIsSubscribing( true ); + setSubscriptionError( null ); + + try { + const siteUrl = site || window.location.origin; + const tzOffset = + timezoneOffset !== undefined + ? timezoneOffset + : new Date().getTimezoneOffset() / -60; // Convert to hours + + const response = await apiFetch( { + path: '/progress-planner/v1/popover/subscribe', + method: 'POST', + data: { + name: emailFrequency.name.trim(), + email: emailFrequency.email.trim(), + site: siteUrl, + timezone_offset: tzOffset, + with_email: 'yes', + }, + } ); + + if ( response.success ) { + // Subscription successful, proceed to next step. + onNext(); + } else { + throw new Error( + response.message || + __( + 'Failed to subscribe. Please try again.', + 'progress-planner' + ) + ); + } + } catch ( error ) { + console.error( 'Failed to subscribe:', error ); + setSubscriptionError( + error.message || + __( + 'Failed to subscribe. Please try again.', + 'progress-planner' + ) + ); + } finally { + setIsSubscribing( false ); + } + } + }; + + return ( + +
    +
    +
    +
    +

    + { __( + 'Stay on track with emails that include recommendations, updates and useful news.', + 'progress-planner' + ) } +

    +

    + { __( + 'Choose how often you want a little nudge to keep your site moving forward.', + 'progress-planner' + ) } +

    +
    +
    +
    +

    + { __( 'Email Frequency', 'progress-planner' ) } +

    + + { subscriptionError && ( +
    + { subscriptionError } +
    + ) } + +
    + { + setEmailFrequency( { + ...emailFrequency, + choice: e.target.value, + } ); + setSubscriptionError( null ); + } } + label={ __( + 'Email me weekly', + 'progress-planner' + ) } + /> + + { + setEmailFrequency( { + ...emailFrequency, + choice: e.target.value, + } ); + setSubscriptionError( null ); + } } + label={ __( + "Don't email me", + 'progress-planner' + ) } + /> +
    + + { emailFrequency.choice === 'weekly' && ( +
    + + +
    + ) } +
    +
    +
    +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/FirstTaskStep.js b/assets/src/components/OnboardingWizard/steps/FirstTaskStep.js new file mode 100644 index 0000000000..6bfe561c79 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/FirstTaskStep.js @@ -0,0 +1,233 @@ +/** + * FirstTaskStep Component + * + * Step for completing the first onboarding task. + * The Next button is hidden - user advances by completing the task. + * + * @package + */ + +import { useState, useEffect, useRef } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { useTaskCompletion } from '../../../hooks/useTaskCompletion'; +import TASK_FORMS from '../TaskForms'; + +/** + * FirstTaskStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} FirstTask step component. + */ +export default function FirstTaskStep( props ) { + const { wizardState, updateState, onNext, stepData, config } = props; + const { ajaxUrl, nonce } = config; + const brandingName = + config?.l10n?.brandingName || + __( 'Progress Planner', 'progress-planner' ); + + const { completeTask } = useTaskCompletion( { + ajaxUrl, + nonce, + } ); + + const [ isCompleting, setIsCompleting ] = useState( false ); + const taskContentRef = useRef( null ); + + const task = stepData?.data?.task; + + // Look up React form component for this task. + const TaskFormComponent = task?.task_id ? TASK_FORMS[ task.task_id ] : null; + + /** + * Handle task completion. + * + * @param {string} taskId - Task ID. + * @param {Object} formValues - Form values from task. + */ + const handleCompleteTask = async ( taskId, formValues = {} ) => { + if ( ! taskId || isCompleting ) { + return; + } + + setIsCompleting( true ); + + try { + await completeTask( taskId, formValues ); + updateState( { + data: { + ...wizardState.data, + firstTaskCompleted: true, + }, + } ); + // Auto-advance to next step. + onNext(); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to complete task:', error ); + setIsCompleting( false ); + } + }; + + /** + * Handle React form submission. + * + * @param {Event} e - The form submit event. + */ + const handleFormSubmit = ( e ) => { + e.preventDefault(); + const formData = new FormData( e.target ); + const formValues = Object.fromEntries( formData.entries() ); + handleCompleteTask( task.task_id, formValues ); + }; + + // Attach click handler after render (for dangerouslySetInnerHTML fallback). + useEffect( () => { + // Skip if using React form component. + if ( TaskFormComponent ) { + return; + } + + if ( ! taskContentRef.current ) { + return; + } + + const btn = taskContentRef.current.querySelector( + '.prpl-complete-task-btn' + ); + if ( ! btn ) { + return; + } + + const handleClick = ( e ) => { + e.preventDefault(); + const button = e.target.closest( 'button' ); + const taskId = button?.dataset?.taskId || task?.task_id; + const form = button?.closest( 'form' ); + + let formValues = {}; + if ( form ) { + const formData = new FormData( form ); + formValues = Object.fromEntries( formData.entries() ); + } + + handleCompleteTask( taskId, formValues ); + }; + + btn.addEventListener( 'click', handleClick ); + + return () => { + btn.removeEventListener( 'click', handleClick ); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ task, TaskFormComponent ] ); + + // Skip step if no task available. + useEffect( () => { + if ( ! task ) { + onNext(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ task ] ); + + if ( ! task ) { + return null; + } + + /** + * Render the task content column. + * + * @return {JSX.Element} Task content. + */ + const renderTaskContent = () => { + // Use React form component if available. + if ( TaskFormComponent ) { + return ( +
    + + + + ); + } + + // Fallback: render dangerouslySetInnerHTML or plain button. + if ( task.template_html ) { + return ( +
    + ); + } + + return ( +
    + { task.title &&

    { task.title }

    } + +
    + ); + }; + + return ( +
    +
    +
    +
    +
    +

    + { __( + 'Ready for your first task and your first point?', + 'progress-planner' + ) } +

    +

    + { sprintf( + /* translators: %s: Progress Planner name */ + __( + "This is an example of a recommendation in %s. It's a task that helps improve your website. Most recommendations can be completed in under five minutes. Once you've completed a recommendation, we'll celebrate your success together and provide you with a new recommendation.", + 'progress-planner' + ), + brandingName + ) } +

    +

    + { __( + "Let's give it a try!", + 'progress-planner' + ) } +

    +
    +
    +
    + { renderTaskContent() } +
    +
    +
    + { /* No footer/Next button - user advances by completing the task */ } +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js b/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js new file mode 100644 index 0000000000..d3a23cb5af --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js @@ -0,0 +1,281 @@ +/** + * MoreTasksStep Component + * + * Step for completing additional tasks with 2 sub-steps: + * 1. Intro screen (can skip to finish) + * 2. Task list screen (uses OnboardTask component) + * + * @package + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; +import NextButton from '../NextButton'; +import OnboardTask from '../OnboardTask'; + +/** + * MoreTasksStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} MoreTasks step component. + */ +export default function MoreTasksStep( props ) { + const { wizardState, updateState, stepData, config } = props; + + const [ currentSubStep, setCurrentSubStep ] = useState( 0 ); + const [ completedTasks, setCompletedTasks ] = useState( {} ); + const [ openTaskId, setOpenTaskId ] = useState( null ); + + const tasks = stepData?.data?.tasks || []; + + // Initialize completed tasks from wizard state. + useEffect( () => { + if ( wizardState.data.moreTasksCompleted ) { + setCompletedTasks( wizardState.data.moreTasksCompleted ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + /** + * Handle task completion. + * + * @param {string} taskId - Completed task ID. + */ + const handleTaskComplete = ( taskId ) => { + setCompletedTasks( ( prev ) => ( { + ...prev, + [ taskId ]: true, + } ) ); + + updateState( { + data: { + ...wizardState.data, + moreTasksCompleted: { + ...completedTasks, + [ taskId ]: true, + }, + }, + } ); + }; + + /** + * Handle continue from intro. + */ + const handleContinue = () => { + setCurrentSubStep( 1 ); + }; + + /** + * Handle finish onboarding. + */ + const handleFinish = async () => { + // Mark wizard as finished. + updateState( { + data: { + ...wizardState.data, + finished: true, + }, + } ); + + // Save progress before redirecting. + // Note: Progress saving is handled by the parent wizard component. + // We just mark as finished and redirect. + + // Finish onboarding - redirect to dashboard. + window.location.href = + config?.lastStepRedirectUrl || + '/wp-admin/admin.php?page=progress-planner'; + }; + + /** + * Render current sub-step. + * + * @return {JSX.Element} Current sub-step content. + */ + const renderSubStep = () => { + if ( currentSubStep === 0 ) { + // Intro sub-step. + return ( +
    +
    +
    +
    +

    + + { __( + 'Well done! Great work so far!', + 'progress-planner' + ) } + +

    +

    + { __( + 'You can take on a few more recommendations if you feel like it, or jump straight to your dashboard.', + 'progress-planner' + ) } +

    +
    +
    + { + e.preventDefault(); + handleFinish(); + } } + > + { __( + 'Take me to the dashboard', + 'progress-planner' + ) } + + +
    +
    +
    +
    + +
    +
    +
    +
    + ); + } + + // Tasks sub-step. + // If a task is open, only show that task's expanded view. + if ( openTaskId ) { + const openTask = tasks.find( ( t ) => t.task_id === openTaskId ); + if ( openTask ) { + return ( +
    + + setOpenTaskId( + isOpen ? openTask.task_id : null + ) + } + forceOpen + disableActionButton + /> +
    + ); + } + } + + // Show task list when no task is open. + return ( +
    +
    +
      + { tasks.map( ( task ) => { + const isTaskCompleted = + completedTasks[ task.task_id ]; + return ( +
    • + + + → + + { task.title } + +
      +
      + + + +1 + +
      + + { '\u2713' } + +
      +
    • + ); + } ) } +
    +
    +
    + ); + }; + + return ( + +
    { renderSubStep() }
    + { /* Show footer only on tasks sub-step when no task is open */ } + { currentSubStep === 1 && ! openTaskId && ( + true } + wizardState={ wizardState } + buttonText={ + <> + { __( + 'Take me to the dashboard', + 'progress-planner' + ) }{ ' ' } + › + + } + buttonClass="prpl-btn-secondary" + /> + ) } +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/SettingsStep.js b/assets/src/components/OnboardingWizard/steps/SettingsStep.js new file mode 100644 index 0000000000..a7f82f6cfa --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/SettingsStep.js @@ -0,0 +1,472 @@ +/** + * SettingsStep Component + * + * Step for configuring settings with 6 internal sub-steps: + * homepage, about, contact, faq, post-types, login-destination + * + * @package + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { ajaxRequest } from '../../../utils/ajaxRequest'; +import OnboardingStep from '../OnboardingStep'; +import NextButton from '../NextButton'; +import { CustomCheckbox, ToggleSwitch } from '../../FormInputs'; + +const SUB_STEPS = [ 'homepage', 'about', 'contact', 'faq', 'post-types' ]; + +/** + * SettingsStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} Settings step component. + */ +export default function SettingsStep( props ) { + const { wizardState, updateState, config } = props; + const { + ajaxUrl, + nonce, + pages = [], + postTypes = [], + pageTypes = {}, + } = config; + + const [ currentSubStep, setCurrentSubStep ] = useState( 0 ); + const [ settings, setSettings ] = useState( () => { + // Default: hasPage: true means checkbox is unchecked (user has a page). + // Default: all post types selected. + const allPostTypeIds = postTypes.map( ( pt ) => pt.id ); + const defaults = { + homepage: { hasPage: true, pageId: null }, + about: { hasPage: true, pageId: null }, + contact: { hasPage: true, pageId: null }, + faq: { hasPage: true, pageId: null }, + 'post-types': { selectedTypes: allPostTypeIds }, + }; + // Merge with saved settings, but ensure hasPage defaults are respected. + if ( wizardState.data.settings ) { + return { ...defaults, ...wizardState.data.settings }; + } + return defaults; + } ); + + const [ isSaving, setIsSaving ] = useState( false ); + + // Update wizard state when settings change. + useEffect( () => { + updateState( { + data: { + ...wizardState.data, + settings, + }, + } ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ settings ] ); + + /** + * Save current sub-step setting. + * + * @param {string} subStepName - Name of sub-step. + * @param {Object} subStepData - Data for sub-step. + */ + const saveSubStep = async ( subStepName, subStepData ) => { + setIsSaving( true ); + try { + // Save individual sub-step via AJAX if needed. + // For now, we'll save all at once at the end. + setSettings( ( prev ) => ( { + ...prev, + [ subStepName ]: subStepData, + } ) ); + } catch ( error ) { + console.error( 'Failed to save setting:', error ); + } finally { + setIsSaving( false ); + } + }; + + /** + * Save all settings at once. + */ + const saveAllSettings = async () => { + setIsSaving( true ); + try { + const pagesData = {}; + [ 'homepage', 'about', 'contact', 'faq' ].forEach( ( pageType ) => { + if ( settings[ pageType ] ) { + pagesData[ pageType ] = { + id: settings[ pageType ].pageId || 0, + have_page: settings[ pageType ].hasPage + ? 'yes' + : 'not-applicable', + }; + } + } ); + + await ajaxRequest( { + url: ajaxUrl, + data: { + action: 'prpl_save_all_onboarding_settings', + nonce, + pages: JSON.stringify( pagesData ), + 'prpl-post-types-include': + settings[ 'post-types' ]?.selectedTypes || [], + }, + } ); + } catch ( error ) { + console.error( 'Failed to save settings:', error ); + } finally { + setIsSaving( false ); + } + }; + + /** + * Handle next sub-step. + */ + const handleNextSubStep = async () => { + const subStepName = SUB_STEPS[ currentSubStep ]; + const subStepData = settings[ subStepName ]; + + // Save current sub-step. + await saveSubStep( subStepName, subStepData ); + + // If last sub-step, save all settings and advance to next step. + if ( currentSubStep === SUB_STEPS.length - 1 ) { + await saveAllSettings(); + // Small delay to ensure settings are saved before advancing. + setTimeout( () => { + props.onNext(); + }, 100 ); + } else { + setCurrentSubStep( currentSubStep + 1 ); + } + }; + + /** + * Render current sub-step. + * + * @return {JSX.Element} Current sub-step content. + */ + const renderSubStep = () => { + const subStepName = SUB_STEPS[ currentSubStep ]; + const subStepData = settings[ subStepName ] || {}; + + switch ( subStepName ) { + case 'homepage': + case 'about': + case 'contact': + case 'faq': { + const pageType = pageTypes[ subStepName ] || {}; + let pageTitle = pageType.title; + if ( ! pageTitle ) { + if ( subStepName === 'homepage' ) { + pageTitle = __( 'Home page', 'progress-planner' ); + } else if ( subStepName === 'about' ) { + pageTitle = __( 'About page', 'progress-planner' ); + } else if ( subStepName === 'contact' ) { + pageTitle = __( 'Contact page', 'progress-planner' ); + } else if ( subStepName === 'faq' ) { + pageTitle = __( 'FAQ page', 'progress-planner' ); + } else { + pageTitle = subStepName; + } + } + const pageDescription = + pageType.description || + __( 'Select a page', 'progress-planner' ); + + return ( +
    +
    +
    +
    +

    { pageDescription }

    +
    +
    +
    +
    +

    + { __( + 'Settings:', + 'progress-planner' + ) }{ ' ' } + { pageTitle } + + { currentSubStep + 1 }/ + { SUB_STEPS.length } + +

    +
    +
    +
    + +
    +
    + { + const noPage = e.target.checked; + setSettings( ( prev ) => ( { + ...prev, + [ subStepName ]: { + ...prev[ subStepName ], + hasPage: ! noPage, + // Reset pageId when checkbox is checked. + pageId: noPage + ? null + : prev[ + subStepName + ]?.pageId, + }, + } ) ); + } } + label={ sprintf( + /* translators: %s: page type title */ + __( + "I don't have a %s yet", + 'progress-planner' + ), + pageTitle + ) } + /> +
    +
    +
    + { /* Note shown when checkbox is checked */ } + { ! subStepData.hasPage && + pageType.note && ( +
    + + + + + +

    { pageType.note }

    +
    + ) } + +
    +
    +
    +
    + ); + } + + case 'post-types': + return ( +
    +
    +
    +
    +

    + { __( + 'Choose the post types you actively use for your content.', + 'progress-planner' + ) } +

    +
    +
    +
    +
    +

    + { __( + 'Settings:', + 'progress-planner' + ) }{ ' ' } + { __( + 'Valuable post types', + 'progress-planner' + ) } + + { currentSubStep + 1 }/ + { SUB_STEPS.length } + +

    +

    + { __( + "We'll track and reward progress only on the ones you select.", + 'progress-planner' + ) } +

    +
    +
    +
    +

    + { __( + 'Which post types do you want us to track?', + 'progress-planner' + ) } +

    +
    + { postTypes.map( ( postType ) => ( + { + const isChecked = + e.target.checked; + setSettings( + ( prev ) => ( { + ...prev, + 'post-types': { + selectedTypes: + isChecked + ? [ + ...( prev[ + 'post-types' + ] + ?.selectedTypes || + [] ), + postType.id, + ] + : ( + prev[ + 'post-types' + ] + ?.selectedTypes || + [] + ).filter( + ( + id + ) => + id !== + postType.id + ), + }, + } ) + ); + } } + label={ postType.title } + /> + ) ) } +
    +
    +
    +
    + +
    +
    +
    +
    + ); + + default: + return null; + } + }; + + /** + * Check if current sub-step can proceed. + * Page sub-steps require either a page selection OR checkbox checked. + * Post-types sub-step requires at least one post type selected. + * + * @return {boolean} True if can proceed. + */ + const canProceed = () => { + if ( isSaving ) { + return false; + } + + const subStepName = SUB_STEPS[ currentSubStep ]; + const subStepData = settings[ subStepName ] || {}; + + // For page selection sub-steps. + if ( + [ 'homepage', 'about', 'contact', 'faq' ].includes( subStepName ) + ) { + // Can proceed if checkbox is checked (no page) OR a page is selected. + return ! subStepData.hasPage || !! subStepData.pageId; + } + + // For post-types, require at least one selected. + if ( subStepName === 'post-types' ) { + return ( + subStepData.selectedTypes && + subStepData.selectedTypes.length > 0 + ); + } + + return true; + }; + + return ( + +
    { renderSubStep() }
    +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/WelcomeStep.js b/assets/src/components/OnboardingWizard/steps/WelcomeStep.js new file mode 100644 index 0000000000..daea8940ec --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/WelcomeStep.js @@ -0,0 +1,209 @@ +/** + * WelcomeStep Component + * + * First step: Privacy policy acceptance and license generation. + * + * @package + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; +import NextButton from '../NextButton'; +import { useLicenseGenerator } from '../../../hooks/useLicenseGenerator'; +import { CustomCheckbox } from '../../FormInputs'; + +/** + * WelcomeStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} Welcome step component. + */ +export default function WelcomeStep( props ) { + const { wizardState, updateState, config } = props; + const { + onboardNonceURL, + onboardAPIUrl, + ajaxUrl, + nonce, + site, + timezoneOffset, + hasLicense, + l10n, + baseUrl, + privacyPolicyUrl, + } = config; + + const { generateLicense, isGenerating } = useLicenseGenerator( { + onboardNonceURL, + onboardAPIUrl, + ajaxUrl, + nonce, + siteUrl: site, + timezoneOffset, + } ); + + const [ privacyAccepted, setPrivacyAccepted ] = useState( + wizardState.data.privacyAccepted || false + ); + + // Update wizard state when privacy acceptance changes. + useEffect( () => { + updateState( { + data: { + ...wizardState.data, + privacyAccepted, + }, + } ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ privacyAccepted ] ); + + /** + * Handle next button click. + */ + const handleNext = async () => { + // If no license and privacy accepted, generate license first. + if ( ! hasLicense && privacyAccepted ) { + try { + await generateLicense( { + 'with-email': 'no', // Default for wizard + } ); + // Continue to next step (don't reload - matches develop branch behavior). + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to generate license:', error ); + return; + } + } + + props.onNext(); + }; + + /** + * Check if can proceed. + * + * @return {boolean} True if can proceed. + */ + const canProceed = () => { + // Sites with license can always proceed. + if ( hasLicense ) { + return true; + } + return privacyAccepted; + }; + + return ( + +
    +
    +
    +
    +

    + { __( + "Hi there! Ready to push your website forward? Let's go!", + 'progress-planner' + ) } +

    +

    + { sprintf( + /* translators: %s: Progress Planner name */ + __( + "%s helps you set clear, focused goals for your website. Let's go through a few simple steps to get everything set up.", + 'progress-planner' + ), + l10n?.brandingName || 'Progress Planner' + ) } +

    +

    + { __( + 'This will only take a few minutes.', + 'progress-planner' + ) } +

    +
    + + { ! hasLicense && ( +
    + { + setPrivacyAccepted( e.target.checked ); + // Remove active class from required indicator (like develop). + const requiredIndicator = + document.querySelector( + '.prpl-privacy-checkbox-wrapper .prpl-required-indicator' + ); + if ( requiredIndicator ) { + requiredIndicator.classList.remove( + 'prpl-required-indicator-active' + ); + } + } } + label={ + <> + { __( + 'I accept the', + 'progress-planner' + ) }{ ' ' } + + { __( + 'privacy policy', + 'progress-planner' + ) } + { ' ' } + { __( + 'and the essential data processing needed for the plugin.', + 'progress-planner' + ) }{ ' ' } + + { __( + 'Required', + 'progress-planner' + ) } + + + } + /> +
    + ) } + + + { __( + 'Start onboarding', + 'progress-planner' + ) } + + + } + buttonClass="prpl-btn-secondary" + /> +
    +
    +
    + +
    +
    +
    +
    +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/WhatsWhatStep.js b/assets/src/components/OnboardingWizard/steps/WhatsWhatStep.js new file mode 100644 index 0000000000..7eab118fc3 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/WhatsWhatStep.js @@ -0,0 +1,66 @@ +/** + * WhatsWhatStep Component + * + * Step explaining what Progress Planner does. + * + * @package + */ + +import { __ } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; + +/** + * WhatsWhatStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} WhatsWhat step component. + */ +export default function WhatsWhatStep( props ) { + return ( + true }> +
    +
    +
    +
    +

    + { __( 'Recommendations', 'progress-planner' ) } +

    +

    + { __( + 'Tasks that show you what to work on next.', + 'progress-planner' + ) } +

    +

    + { __( + 'These actions help you improve your site step by step, without having to guess where to start. Most recommendations can be completed in under five minutes.', + 'progress-planner' + ) } +

    +
    +
    +
    +
    +

    { __( 'Badges', 'progress-planner' ) }

    +

    + + +1 + { ' ' } + { __( + 'You earn points for every completed task.', + 'progress-planner' + ) } +

    +

    + { __( + 'Collect badges as you make progress, which keeps things fun and helps you stay motivated!', + 'progress-planner' + ) } +

    +
    +
    +
    +
    +
    + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/BadgesStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/BadgesStep.test.js new file mode 100644 index 0000000000..24390189bd --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/BadgesStep.test.js @@ -0,0 +1,302 @@ +/** + * Tests for BadgesStep Component + */ + +import { render, screen } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +// Mock OnboardingStep component +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    + + { props.children } +
    +) ); + +// Mock Gauge component +jest.mock( '../../../Gauge', () => ( props ) => ( +
    + { props.children } +
    +) ); + +// Mock Badge component +jest.mock( '../../../Badge', () => ( props ) => ( +
    +) ); + +// Import after mocks +import BadgesStep from '../BadgesStep'; + +describe( 'BadgesStep', () => { + const defaultProps = { + wizardState: { + data: { + firstTaskCompleted: false, + }, + }, + updateState: jest.fn(), + config: {}, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { + id: 'onboarding-step-badges', + data: { + badgeId: 'monthly-2024-m12', + badgeName: 'December Badge', + maxPoints: 10, + currentValue: 0, + brandingId: 'progress-planner', + }, + }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders congratulations heading', () => { + render( ); + + expect( + screen.getByText( /Whoohoo, nice one!/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders badge explanation text', () => { + render( ); + + expect( + screen.getByText( /Gather ten points this month/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders encouragement text', () => { + render( ); + + expect( + screen.getByText( /off to a great start/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders monthly badge label', () => { + render( ); + + expect( screen.getByText( 'Monthly badge' ) ).toBeInTheDocument(); + } ); + + it( 'renders next button with Got it text', () => { + render( ); + + const button = screen.getByTestId( 'next-button' ); + expect( button ).toHaveTextContent( 'Got it' ); + } ); + } ); + + describe( 'gauge component', () => { + it( 'renders gauge wrapper', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'renders gauge element when badge data available', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'renders gauge even when no badgeId (fallback gauge)', () => { + const propsWithoutBadge = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: {}, + }, + }; + + const { container } = render( + + ); + + // The component renders a fallback Gauge even without badgeId + expect( + container.querySelector( '.prpl-gauge' ) + ).toBeInTheDocument(); + } ); + + it( 'does not render Badge component when no badgeName', () => { + const propsWithoutName = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: { + badgeId: 'monthly-2024-m12', + }, + }, + }; + + const { container } = render( + + ); + + // The Badge component is not rendered without badgeName + expect( + container.querySelector( '.prpl-badge' ) + ).not.toBeInTheDocument(); + } ); + + it( 'sets correct gauge attributes', () => { + const { container } = render( ); + + const gauge = container.querySelector( '.prpl-gauge' ); + expect( gauge ).toHaveAttribute( 'data-max', '10' ); + expect( gauge ).toHaveAttribute( 'data-value', '0' ); + } ); + + it( 'renders Badge component with correct badge data', () => { + const { container } = render( ); + + const badge = container.querySelector( '.prpl-badge' ); + expect( badge ).toBeInTheDocument(); + expect( badge ).toHaveAttribute( + 'data-badge-id', + 'monthly-2024-m12' + ); + expect( badge ).toHaveAttribute( + 'data-badge-name', + 'December Badge' + ); + } ); + + it( 'uses default maxPoints when not provided', () => { + const propsWithoutMax = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: { + badgeId: 'test-badge', + badgeName: 'Test Badge', + }, + }, + }; + + const { container } = render( + + ); + + const gauge = container.querySelector( '.prpl-gauge' ); + expect( gauge ).toHaveAttribute( 'data-max', '10' ); + } ); + + it( 'uses default currentValue when not provided', () => { + const propsWithoutValue = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: { + badgeId: 'test-badge', + badgeName: 'Test Badge', + }, + }, + }; + + const { container } = render( + + ); + + const gauge = container.querySelector( '.prpl-gauge' ); + expect( gauge ).toHaveAttribute( 'data-value', '0' ); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + + it( 'renders background content', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-background-content' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'step data handling', () => { + it( 'handles missing stepData', () => { + const propsWithoutStepData = { + ...defaultProps, + stepData: null, + }; + + const { container } = render( + + ); + + // Should still render without Badge component + expect( screen.getByText( 'Monthly badge' ) ).toBeInTheDocument(); + expect( + container.querySelector( '.prpl-badge' ) + ).not.toBeInTheDocument(); + } ); + + it( 'handles missing stepData.data', () => { + const propsWithoutData = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + }, + }; + + const { container } = render( + + ); + + expect( screen.getByText( 'Monthly badge' ) ).toBeInTheDocument(); + expect( + container.querySelector( '.prpl-badge' ) + ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/EmailFrequencyStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/EmailFrequencyStep.test.js new file mode 100644 index 0000000000..32c85c5f40 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/EmailFrequencyStep.test.js @@ -0,0 +1,320 @@ +/** + * Tests for EmailFrequencyStep Component + */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +// Mock OnboardingStep component +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    + + { props.children } +
    +) ); + +// Import after mocks +import EmailFrequencyStep from '../EmailFrequencyStep'; +import apiFetch from '@wordpress/api-fetch'; + +describe( 'EmailFrequencyStep', () => { + const defaultConfig = { + userFirstName: 'John', + userEmail: 'john@example.com', + site: 'https://example.com', + timezoneOffset: 0, + }; + + const defaultProps = { + wizardState: { + data: {}, + }, + updateState: jest.fn(), + config: defaultConfig, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { id: 'onboarding-step-email-frequency' }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + apiFetch.mockResolvedValue( { success: true } ); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders email frequency heading', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'Email Frequency' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders explanation text', () => { + render( ); + + expect( + screen.getByText( /Stay on track with emails/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders weekly option', () => { + render( ); + + expect( + screen.getByLabelText( /Email me weekly/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders dont email option', () => { + render( ); + + expect( + screen.getByLabelText( /Don't email me/ ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'default state', () => { + it( 'has weekly selected by default', () => { + render( ); + + const weeklyRadio = screen.getByLabelText( /Email me weekly/ ); + expect( weeklyRadio ).toBeChecked(); + } ); + + it( 'shows name and email fields when weekly selected', () => { + render( ); + + expect( screen.getByLabelText( 'First name' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Email' ) ).toBeInTheDocument(); + } ); + + it( 'populates name field from config', () => { + render( ); + + expect( screen.getByLabelText( 'First name' ) ).toHaveValue( + 'John' + ); + } ); + + it( 'populates email field from config', () => { + render( ); + + expect( screen.getByLabelText( 'Email' ) ).toHaveValue( + 'john@example.com' + ); + } ); + } ); + + describe( 'dont email option', () => { + it( 'hides form when dont email selected', () => { + render( ); + + const dontEmailRadio = screen.getByLabelText( /Don't email me/ ); + fireEvent.click( dontEmailRadio ); + + expect( + screen.queryByLabelText( 'First name' ) + ).not.toBeInTheDocument(); + } ); + + it( 'can proceed immediately with dont email', () => { + render( ); + + const dontEmailRadio = screen.getByLabelText( /Don't email me/ ); + fireEvent.click( dontEmailRadio ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).not.toBeDisabled(); + } ); + + it( 'calls onNext without API call for dont email', async () => { + const onNext = jest.fn(); + + render( + + ); + + const dontEmailRadio = screen.getByLabelText( /Don't email me/ ); + fireEvent.click( dontEmailRadio ); + + const nextBtn = screen.getByTestId( 'next-button' ); + await act( async () => { + fireEvent.click( nextBtn ); + } ); + + expect( apiFetch ).not.toHaveBeenCalled(); + expect( onNext ).toHaveBeenCalled(); + } ); + } ); + + describe( 'weekly option validation', () => { + it( 'cannot proceed without name', () => { + const configWithoutName = { + ...defaultConfig, + userFirstName: '', + }; + + render( + + ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).toBeDisabled(); + } ); + + it( 'cannot proceed without email', () => { + const configWithoutEmail = { + ...defaultConfig, + userEmail: '', + }; + + render( + + ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).toBeDisabled(); + } ); + + it( 'can proceed with name and email', () => { + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).not.toBeDisabled(); + } ); + } ); + + describe( 'subscription API call', () => { + it( 'calls API when subscribing', async () => { + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + await act( async () => { + fireEvent.click( nextBtn ); + } ); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + path: '/progress-planner/v1/popover/subscribe', + method: 'POST', + } ) + ); + } ); + + it( 'calls onNext on successful subscription', async () => { + const onNext = jest.fn(); + apiFetch.mockResolvedValue( { success: true } ); + + render( + + ); + + const nextBtn = screen.getByTestId( 'next-button' ); + await act( async () => { + fireEvent.click( nextBtn ); + } ); + + expect( onNext ).toHaveBeenCalled(); + } ); + + it( 'shows error on failed subscription', async () => { + apiFetch.mockRejectedValue( new Error( 'Subscription failed' ) ); + jest.spyOn( console, 'error' ).mockImplementation( () => {} ); + + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + await act( async () => { + fireEvent.click( nextBtn ); + } ); + + expect( + screen.getByText( /Subscription failed|Failed to subscribe/ ) + ).toBeInTheDocument(); + + console.error.mockRestore(); + } ); + } ); + + describe( 'form input handling', () => { + it( 'updates name field', () => { + render( ); + + const nameInput = screen.getByLabelText( 'First name' ); + fireEvent.change( nameInput, { target: { value: 'Jane' } } ); + + expect( nameInput ).toHaveValue( 'Jane' ); + } ); + + it( 'updates email field', () => { + render( ); + + const emailInput = screen.getByLabelText( 'Email' ); + fireEvent.change( emailInput, { + target: { value: 'jane@example.com' }, + } ); + + expect( emailInput ).toHaveValue( 'jane@example.com' ); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + + it( 'renders email options container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-email-frequency-options' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/FirstTaskStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/FirstTaskStep.test.js new file mode 100644 index 0000000000..2fcf5870be --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/FirstTaskStep.test.js @@ -0,0 +1,392 @@ +/** + * Tests for FirstTaskStep Component + */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, + sprintf: ( str, ...args ) => { + let result = str; + args.forEach( ( arg ) => { + result = result.replace( '%s', arg ); + } ); + return result; + }, +} ) ); + +// Mock useTaskCompletion hook +jest.mock( '../../../../hooks/useTaskCompletion', () => ( { + useTaskCompletion: jest.fn( () => ( { + completeTask: jest.fn().mockResolvedValue( {} ), + isCompleting: false, + } ) ), +} ) ); + +// Import after mocks +import FirstTaskStep from '../FirstTaskStep'; +import { useTaskCompletion } from '../../../../hooks/useTaskCompletion'; + +describe( 'FirstTaskStep', () => { + const mockTask = { + task_id: 'first-task', + title: 'First Task Title', + url: 'https://example.com/first-task', + action_label: 'Complete Task', + }; + + const defaultConfig = { + ajaxUrl: '/wp-admin/admin-ajax.php', + nonce: 'test-nonce', + l10n: { brandingName: 'Progress Planner' }, + }; + + const defaultProps = { + wizardState: { + data: { + firstTaskCompleted: false, + }, + }, + updateState: jest.fn(), + config: defaultConfig, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { + id: 'onboarding-step-first-task', + data: { + task: mockTask, + }, + }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + useTaskCompletion.mockReturnValue( { + completeTask: jest.fn().mockResolvedValue( {} ), + isCompleting: false, + } ); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders first task heading', () => { + render( ); + + expect( + screen.getByText( /Ready for your first task/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders explanation text', () => { + render( ); + + expect( + screen.getByText( /This is an example of a recommendation/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders encouragement text', () => { + render( ); + + expect( + screen.getByText( /Let's give it a try/ ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'task display', () => { + it( 'renders task title', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'First Task Title' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders task action button with action label', () => { + render( ); + + expect( + screen.getByRole( 'button', { name: 'Complete Task' } ) + ).toBeInTheDocument(); + } ); + + it( 'uses default action label when not provided', () => { + const taskWithoutLabel = { + ...mockTask, + action_label: undefined, + }; + + render( + + ); + + expect( + screen.getByRole( 'button', { name: 'Mark as complete' } ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'task completion', () => { + it( 'calls completeTask when button clicked', async () => { + const mockCompleteTask = jest.fn().mockResolvedValue( {} ); + useTaskCompletion.mockReturnValue( { + completeTask: mockCompleteTask, + isCompleting: false, + } ); + + render( ); + + const button = screen.getByRole( 'button', { + name: 'Complete Task', + } ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( mockCompleteTask ).toHaveBeenCalledWith( 'first-task', {} ); + } ); + + it( 'updates wizard state on completion', async () => { + const updateState = jest.fn(); + const mockCompleteTask = jest.fn().mockResolvedValue( {} ); + useTaskCompletion.mockReturnValue( { + completeTask: mockCompleteTask, + isCompleting: false, + } ); + + render( + + ); + + const button = screen.getByRole( 'button', { + name: 'Complete Task', + } ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( updateState ).toHaveBeenCalledWith( + expect.objectContaining( { + data: expect.objectContaining( { + firstTaskCompleted: true, + } ), + } ) + ); + } ); + + it( 'auto-advances after completion', async () => { + const onNext = jest.fn(); + const mockCompleteTask = jest.fn().mockResolvedValue( {} ); + useTaskCompletion.mockReturnValue( { + completeTask: mockCompleteTask, + isCompleting: false, + } ); + + render( ); + + const button = screen.getByRole( 'button', { + name: 'Complete Task', + } ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( onNext ).toHaveBeenCalled(); + } ); + + it( 'shows completing state while task is being completed', async () => { + let resolveComplete; + const mockCompleteTask = jest.fn( + () => + new Promise( ( resolve ) => { + resolveComplete = resolve; + } ) + ); + useTaskCompletion.mockReturnValue( { + completeTask: mockCompleteTask, + isCompleting: false, + } ); + + render( ); + + const button = screen.getByRole( 'button', { + name: 'Complete Task', + } ); + + await act( async () => { + fireEvent.click( button ); + } ); + + // Button should now show completing state + expect( + screen.getByRole( 'button', { name: 'Completing…' } ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: 'Completing…' } ) + ).toBeDisabled(); + + // Clean up the pending promise + await act( async () => { + resolveComplete( {} ); + } ); + } ); + } ); + + describe( 'no task handling', () => { + it( 'returns null when no task', () => { + const propsWithoutTask = { + ...defaultProps, + stepData: { + id: 'onboarding-step-first-task', + data: {}, + }, + }; + + const { container } = render( + + ); + + expect( container.firstChild ).toBeNull(); + } ); + + it( 'calls onNext when no task', () => { + const onNext = jest.fn(); + const propsWithoutTask = { + ...defaultProps, + onNext, + stepData: { + id: 'onboarding-step-first-task', + data: {}, + }, + }; + + render( ); + + expect( onNext ).toHaveBeenCalled(); + } ); + } ); + + describe( 'template HTML rendering', () => { + it( 'renders template HTML when provided', () => { + const taskWithTemplate = { + ...mockTask, + template_html: '
    Custom Content
    ', + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.custom-task' ) + ).toBeInTheDocument(); + } ); + + it( 'does not render standard task UI when template provided', () => { + const taskWithTemplate = { + ...mockTask, + template_html: '
    Custom Content
    ', + }; + + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-first-task-content' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'branding', () => { + it( 'uses branding name in description', () => { + render( ); + + expect( + screen.getByText( /recommendation in Progress Planner/ ) + ).toBeInTheDocument(); + } ); + + it( 'falls back to default branding', () => { + const configWithoutBranding = { + ...defaultConfig, + l10n: {}, + }; + + render( + + ); + + expect( + screen.getByText( /recommendation in Progress Planner/ ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + + it( 'renders background content', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-background-content' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/MoreTasksStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/MoreTasksStep.test.js new file mode 100644 index 0000000000..617fbe3d16 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/MoreTasksStep.test.js @@ -0,0 +1,335 @@ +/** + * Tests for MoreTasksStep Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +// Mock OnboardingStep component - respect hideFooter prop +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    { props.children }
    +) ); + +// Mock OnboardTask component +jest.mock( '../../OnboardTask', () => ( props ) => ( +
    + { props.task.task_title } + +
    +) ); + +// Import after mocks +import MoreTasksStep from '../MoreTasksStep'; + +describe( 'MoreTasksStep', () => { + const mockTasks = [ + { task_id: 'task-1', title: 'Task 1' }, + { task_id: 'task-2', title: 'Task 2' }, + ]; + + const defaultConfig = { + lastStepRedirectUrl: '/wp-admin/admin.php?page=progress-planner', + }; + + const defaultProps = { + wizardState: { + data: { + moreTasksCompleted: {}, + }, + }, + updateState: jest.fn(), + config: defaultConfig, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { + id: 'onboarding-step-more-tasks', + data: { + tasks: mockTasks, + }, + }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + delete window.location; + window.location = { href: '' }; + } ); + + describe( 'intro sub-step', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders intro sub-step by default', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '[data-substep="more-tasks-intro"]' ) + ).toBeInTheDocument(); + } ); + + it( 'renders congratulations message', () => { + render( ); + + expect( + screen.getByText( /Well done! Great work so far!/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders dashboard link', () => { + render( ); + + expect( + screen.getByText( 'Take me to the dashboard' ) + ).toBeInTheDocument(); + } ); + + it( 'renders continue button', () => { + render( ); + + expect( + screen.getByText( /Let's tackle more tasks/ ) + ).toBeInTheDocument(); + } ); + + it( 'dashboard link has correct href', () => { + render( ); + + const link = screen.getByText( 'Take me to the dashboard' ); + expect( link ).toHaveAttribute( + 'href', + '/wp-admin/admin.php?page=progress-planner' + ); + } ); + + it( 'continue button has correct class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-more-tasks-continue' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'navigation between sub-steps', () => { + it( 'continues to tasks sub-step when continue clicked', () => { + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '[data-substep="more-tasks-tasks"]' ) + ).toBeInTheDocument(); + } ); + + it( 'hides intro when on tasks sub-step', () => { + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '[data-substep="more-tasks-intro"]' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'tasks sub-step', () => { + it( 'renders task list', () => { + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '.prpl-task-list' ) + ).toBeInTheDocument(); + } ); + + it( 'renders tasks from stepData', () => { + render( ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + // The component renders task.title in a span.task-title + expect( screen.getByText( 'Task 1' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Task 2' ) ).toBeInTheDocument(); + } ); + + it( 'renders task list container', () => { + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '.prpl-task-list' ) + ).toBeInTheDocument(); + } ); + + it( 'renders finish button on tasks sub-step', () => { + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + // NextButton is rendered as the footer with "Take me to the dashboard" text + const footerBtns = container.querySelectorAll( '.prpl-tour-next' ); + expect( footerBtns.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'finish onboarding', () => { + it( 'marks wizard as finished on finish', () => { + const updateState = jest.fn(); + + render( + + ); + + const link = screen.getByText( 'Take me to the dashboard' ); + fireEvent.click( link ); + + expect( updateState ).toHaveBeenCalledWith( + expect.objectContaining( { + data: expect.objectContaining( { + finished: true, + } ), + } ) + ); + } ); + + it( 'uses custom redirect URL if provided', () => { + const customConfig = { + lastStepRedirectUrl: '/custom/redirect', + }; + + render( + + ); + + const link = screen.getByText( 'Take me to the dashboard' ); + expect( link ).toHaveAttribute( 'href', '/custom/redirect' ); + } ); + } ); + + describe( 'empty tasks', () => { + it( 'handles empty tasks array', () => { + const propsWithNoTasks = { + ...defaultProps, + stepData: { + id: 'onboarding-step-more-tasks', + data: { + tasks: [], + }, + }, + }; + + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '.prpl-task-list' ) + ).toBeInTheDocument(); + } ); + + it( 'handles missing tasks', () => { + const propsWithoutTasks = { + ...defaultProps, + stepData: { + id: 'onboarding-step-more-tasks', + data: {}, + }, + }; + + const { container } = render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + container.querySelector( '.prpl-task-list' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper on intro', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + + it( 'renders background content', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-background-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders intro buttons container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-more-tasks-intro-buttons' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/SettingsStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/SettingsStep.test.js new file mode 100644 index 0000000000..de188995a0 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/SettingsStep.test.js @@ -0,0 +1,310 @@ +/** + * Tests for SettingsStep Component + */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, + sprintf: ( str, ...args ) => { + let result = str; + args.forEach( ( arg ) => { + result = result.replace( '%s', arg ); + } ); + return result; + }, +} ) ); + +// Mock ajaxRequest +jest.mock( '../../../../utils/ajaxRequest', () => ( { + ajaxRequest: jest.fn().mockResolvedValue( {} ), +} ) ); + +// Mock OnboardingStep component +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    { props.children }
    +) ); + +// Import after mocks +import SettingsStep from '../SettingsStep'; + +describe( 'SettingsStep', () => { + const mockPages = [ + { id: 1, title: 'Home' }, + { id: 2, title: 'About Us' }, + { id: 3, title: 'Contact' }, + ]; + + const mockPostTypes = [ + { id: 'post', title: 'Posts' }, + { id: 'page', title: 'Pages' }, + ]; + + const mockPageTypes = { + homepage: { + title: 'Homepage', + description: 'Select your homepage', + }, + about: { + title: 'About Page', + description: 'Select your about page', + }, + }; + + const defaultConfig = { + ajaxUrl: '/wp-admin/admin-ajax.php', + nonce: 'test-nonce', + pages: mockPages, + postTypes: mockPostTypes, + pageTypes: mockPageTypes, + }; + + const defaultProps = { + wizardState: { + data: {}, + }, + updateState: jest.fn(), + config: defaultConfig, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { id: 'onboarding-step-settings' }, + }; + + /** + * Helper: advance one sub-step by checking the "I don't have a page" checkbox + * then clicking Save setting. + */ + const advanceSubStep = async () => { + // Check the "I don't have a ..." checkbox to enable the Save button + const checkbox = screen.getByRole( 'checkbox' ); + fireEvent.click( checkbox ); + + const saveBtn = screen.getByRole( 'button', { + name: /Save setting/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders homepage sub-step first', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '[data-page="homepage"]' ) + ).toBeInTheDocument(); + } ); + + it( 'renders settings title', () => { + render( ); + + expect( screen.getByText( /Settings:/ ) ).toBeInTheDocument(); + } ); + + it( 'renders progress indicator', () => { + const { container } = render( + + ); + + // SUB_STEPS has 5 items, so progress is "1/5" + const progressSpan = container.querySelector( + '.prpl-settings-progress' + ); + expect( progressSpan ).toBeInTheDocument(); + expect( progressSpan ).toHaveTextContent( '1/5' ); + } ); + + it( 'renders save button', () => { + render( ); + + expect( + screen.getByRole( 'button', { name: /Save setting/ } ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'page selection sub-steps', () => { + it( 'renders page select dropdown', () => { + render( ); + + expect( screen.getByRole( 'combobox' ) ).toBeInTheDocument(); + } ); + + it( 'renders page options from config', () => { + render( ); + + expect( + screen.getByRole( 'option', { name: 'Home' } ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'option', { name: 'About Us' } ) + ).toBeInTheDocument(); + expect( + screen.getByRole( 'option', { name: 'Contact' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders default select option', () => { + render( ); + + expect( + screen.getByRole( 'option', { name: /Select page/ } ) + ).toBeInTheDocument(); + } ); + + it( 'renders no page checkbox', () => { + render( ); + + expect( screen.getByRole( 'checkbox' ) ).toBeInTheDocument(); + } ); + + it( 'renders description from pageTypes', () => { + render( ); + + expect( + screen.getByText( 'Select your homepage' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'sub-step navigation', () => { + it( 'advances to about sub-step', async () => { + const { container } = render( + + ); + + await advanceSubStep(); + + expect( + container.querySelector( '[data-page="about"]' ) + ).toBeInTheDocument(); + } ); + + it( 'updates progress indicator', async () => { + const { container } = render( + + ); + + await advanceSubStep(); + + const progressSpan = container.querySelector( + '.prpl-settings-progress' + ); + expect( progressSpan ).toHaveTextContent( '2/5' ); + } ); + + it( 'advances through all page sub-steps', async () => { + const { container } = render( + + ); + + // SUB_STEPS: homepage, about, contact, faq, post-types + const pageTypes = [ 'homepage', 'about', 'contact', 'faq' ]; + + for ( let index = 0; index < pageTypes.length; index++ ) { + expect( + container.querySelector( + `[data-page="${ pageTypes[ index ] }"]` + ) + ).toBeInTheDocument(); + + if ( index < pageTypes.length - 1 ) { + await advanceSubStep(); + } + } + } ); + } ); + + describe( 'post-types sub-step', () => { + it( 'renders post types sub-step', async () => { + const { container } = render( + + ); + + // Navigate through 4 page sub-steps to reach post-types + for ( let i = 0; i < 4; i++ ) { + await advanceSubStep(); + } + + expect( + container.querySelector( '[data-page="post-types"]' ) + ).toBeInTheDocument(); + } ); + + it( 'renders post type toggle switches', async () => { + render( ); + + // Navigate through 4 page sub-steps to reach post-types + for ( let i = 0; i < 4; i++ ) { + await advanceSubStep(); + } + + // ToggleSwitch renders labels with post type titles + expect( screen.getByText( 'Posts' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Pages' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'state management', () => { + it( 'calls updateState when settings change', () => { + const updateState = jest.fn(); + + render( + + ); + + const select = screen.getByRole( 'combobox' ); + fireEvent.change( select, { target: { value: '1' } } ); + + expect( updateState ).toHaveBeenCalled(); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders setting item container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-setting-item' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/WelcomeStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/WelcomeStep.test.js new file mode 100644 index 0000000000..e18873c9fe --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/WelcomeStep.test.js @@ -0,0 +1,369 @@ +/** + * Tests for WelcomeStep Component + */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, + sprintf: ( str, ...args ) => { + let result = str; + args.forEach( ( arg, i ) => { + result = result.replace( `%${ i + 1 }$s`, arg ); + result = result.replace( '%s', arg ); + } ); + return result; + }, +} ) ); + +// Mock useLicenseGenerator hook +jest.mock( '../../../../hooks/useLicenseGenerator', () => ( { + useLicenseGenerator: jest.fn( () => ( { + generateLicense: jest.fn().mockResolvedValue( {} ), + isGenerating: false, + } ) ), +} ) ); + +// Mock OnboardingStep component - respect hideFooter prop +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    { props.children }
    +) ); + +// Import after mocks +import WelcomeStep from '../WelcomeStep'; +import { useLicenseGenerator } from '../../../../hooks/useLicenseGenerator'; + +describe( 'WelcomeStep', () => { + const defaultConfig = { + onboardNonceURL: 'https://example.com/nonce', + onboardAPIUrl: 'https://api.example.com', + ajaxUrl: '/wp-admin/admin-ajax.php', + nonce: 'test-nonce', + site: 'https://example.com', + timezoneOffset: 0, + hasLicense: true, + l10n: { brandingName: 'Progress Planner' }, + baseUrl: '/wp-content/plugins/progress-planner', + privacyPolicyUrl: 'https://progressplanner.com/privacy-policy/', + }; + + const defaultProps = { + wizardState: { + data: { + privacyAccepted: false, + }, + }, + updateState: jest.fn(), + config: defaultConfig, + onNext: jest.fn(), + onBack: null, + stepData: { id: 'onboarding-step-welcome' }, + }; + + beforeEach( () => { + jest.clearAllMocks(); + useLicenseGenerator.mockReturnValue( { + generateLicense: jest.fn().mockResolvedValue( {} ), + isGenerating: false, + } ); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders tour title', () => { + render( ); + + expect( + screen.getByText( /Ready to push your website forward/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders description text', () => { + render( ); + + expect( + screen.getByText( /helps you set clear, focused goals/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders time message', () => { + render( ); + + expect( + screen.getByText( 'This will only take a few minutes.' ) + ).toBeInTheDocument(); + } ); + + it( 'renders next button', () => { + const { container } = render( ); + + expect( + container.querySelector( '.prpl-tour-next' ) + ).toBeInTheDocument(); + } ); + + it( 'renders welcome graphic image', () => { + const { container } = render( ); + + const img = container.querySelector( 'img' ); + expect( img ).toBeInTheDocument(); + } ); + } ); + + describe( 'with license', () => { + it( 'does not show privacy checkbox when has license', () => { + render( ); + + expect( + screen.queryByLabelText( /I accept the/ ) + ).not.toBeInTheDocument(); + } ); + + it( 'can proceed without privacy acceptance when has license', () => { + const { container } = render( ); + + const button = container.querySelector( '.prpl-tour-next' ); + expect( button ).not.toHaveClass( 'prpl-btn-disabled' ); + } ); + + it( 'calls onNext when button clicked with license', async () => { + const onNext = jest.fn(); + + const { container } = render( + + ); + + const button = container.querySelector( '.prpl-tour-next' ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( onNext ).toHaveBeenCalled(); + } ); + } ); + + describe( 'without license', () => { + const configWithoutLicense = { + ...defaultConfig, + hasLicense: false, + }; + + const propsWithoutLicense = { + ...defaultProps, + config: configWithoutLicense, + }; + + it( 'shows privacy checkbox when no license', () => { + render( ); + + expect( screen.getByRole( 'checkbox' ) ).toBeInTheDocument(); + } ); + + it( 'privacy checkbox is unchecked by default', () => { + render( ); + + expect( screen.getByRole( 'checkbox' ) ).not.toBeChecked(); + } ); + + it( 'cannot proceed without privacy acceptance', () => { + const { container } = render( + + ); + + const button = container.querySelector( '.prpl-tour-next' ); + expect( button ).toHaveClass( 'prpl-btn-disabled' ); + } ); + + it( 'can proceed after accepting privacy', () => { + const { container } = render( + + ); + + const checkbox = screen.getByRole( 'checkbox' ); + fireEvent.click( checkbox ); + + const button = container.querySelector( '.prpl-tour-next' ); + expect( button ).not.toHaveClass( 'prpl-btn-disabled' ); + } ); + + it( 'updates wizard state when privacy accepted', () => { + const updateState = jest.fn(); + + render( + + ); + + const checkbox = screen.getByRole( 'checkbox' ); + fireEvent.click( checkbox ); + + expect( updateState ).toHaveBeenCalledWith( + expect.objectContaining( { + data: expect.objectContaining( { + privacyAccepted: true, + } ), + } ) + ); + } ); + + it( 'renders privacy checkbox wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-privacy-checkbox-wrapper' ) + ).toBeInTheDocument(); + } ); + + it( 'renders checkbox label with privacy text', () => { + render( ); + + expect( screen.getByText( /I accept the/ ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'license generation', () => { + const configWithoutLicense = { + ...defaultConfig, + hasLicense: false, + }; + + const propsWithoutLicense = { + ...defaultProps, + config: configWithoutLicense, + wizardState: { + data: { + privacyAccepted: true, + }, + }, + }; + + it( 'calls generateLicense when proceeding without license', async () => { + const mockGenerateLicense = jest.fn().mockResolvedValue( {} ); + useLicenseGenerator.mockReturnValue( { + generateLicense: mockGenerateLicense, + isGenerating: false, + } ); + + const { container } = render( + + ); + + const button = container.querySelector( '.prpl-tour-next' ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( mockGenerateLicense ).toHaveBeenCalledWith( { + 'with-email': 'no', + } ); + } ); + + it( 'shows spinner when generating license', () => { + useLicenseGenerator.mockReturnValue( { + generateLicense: jest.fn().mockResolvedValue( {} ), + isGenerating: true, + } ); + + const { container } = render( + + ); + + expect( container.querySelector( '.spinner' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'initial state', () => { + it( 'uses privacyAccepted from wizard state', () => { + const propsWithAccepted = { + ...defaultProps, + config: { + ...defaultConfig, + hasLicense: false, + }, + wizardState: { + data: { + privacyAccepted: true, + }, + }, + }; + + render( ); + + expect( screen.getByRole( 'checkbox' ) ).toBeChecked(); + } ); + } ); + + describe( 'branding', () => { + it( 'uses branding name from l10n', () => { + render( ); + + expect( + screen.getByText( /Progress Planner helps you/ ) + ).toBeInTheDocument(); + } ); + + it( 'falls back to default branding name', () => { + const configWithoutBranding = { + ...defaultConfig, + l10n: {}, + }; + + render( + + ); + + expect( + screen.getByText( /Progress Planner helps you/ ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'image path', () => { + it( 'uses baseUrl for image path', () => { + const { container } = render( ); + + const img = container.querySelector( 'img' ); + expect( img ).toHaveAttribute( + 'src', + expect.stringContaining( + '/wp-content/plugins/progress-planner' + ) + ); + } ); + + it( 'handles missing baseUrl', () => { + const configWithoutBaseUrl = { + ...defaultConfig, + baseUrl: undefined, + }; + + const { container } = render( + + ); + + const img = container.querySelector( 'img' ); + expect( img ).toHaveAttribute( + 'src', + expect.stringContaining( 'thumbs_up_ravi_rtl.svg' ) + ); + } ); + } ); +} ); diff --git a/assets/src/components/OnboardingWizard/steps/__tests__/WhatsWhatStep.test.js b/assets/src/components/OnboardingWizard/steps/__tests__/WhatsWhatStep.test.js new file mode 100644 index 0000000000..bd591e4ba7 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/WhatsWhatStep.test.js @@ -0,0 +1,149 @@ +/** + * Tests for WhatsWhatStep Component + */ + +import { render, screen } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +// Mock OnboardingStep component +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
    { props.children }
    +) ); + +// Import after mocks +import WhatsWhatStep from '../WhatsWhatStep'; + +describe( 'WhatsWhatStep', () => { + const defaultProps = { + wizardState: { data: {} }, + updateState: jest.fn(), + config: {}, + onNext: jest.fn(), + onBack: jest.fn(), + stepData: { id: 'onboarding-step-whats-what' }, + }; + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( 'onboarding-step' ) + ).toBeInTheDocument(); + } ); + + it( 'renders recommendations heading', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'Recommendations' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders badges heading', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'Badges' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders recommendations description', () => { + render( ); + + expect( + screen.getByText( /Tasks that show you what to work on next/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders badges description', () => { + render( ); + + expect( + screen.getByText( /You earn points for every completed task/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders points badge indicator', () => { + render( ); + + expect( screen.getByText( '+1' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'layout', () => { + it( 'renders tour content container', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.tour-content' ) + ).toBeInTheDocument(); + } ); + + it( 'renders columns wrapper', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-columns-wrapper-flex' ) + ).toBeInTheDocument(); + } ); + + it( 'renders two columns', () => { + const { container } = render( + + ); + + expect( container.querySelectorAll( '.prpl-column' ) ).toHaveLength( + 2 + ); + } ); + + it( 'renders background content sections', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll( '.prpl-background-content' ) + ).toHaveLength( 2 ); + } ); + + it( 'renders points badge with correct class', () => { + const { container } = render( + + ); + + expect( + container.querySelector( '.prpl-suggested-task-points' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'text content', () => { + it( 'renders step-by-step improvement text', () => { + render( ); + + expect( + screen.getByText( /help you improve your site step by step/ ) + ).toBeInTheDocument(); + } ); + + it( 'renders motivation text', () => { + render( ); + + expect( + screen.getByText( + /keeps things fun and helps you stay motivated/ + ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/assets/src/components/Popovers/AIOSEOPopover.js b/assets/src/components/Popovers/AIOSEOPopover.js new file mode 100644 index 0000000000..e04613e998 --- /dev/null +++ b/assets/src/components/Popovers/AIOSEOPopover.js @@ -0,0 +1,155 @@ +/** + * AIOSEO Popover Component. + * + * Generic popover for AIOSEO settings. + * + * @param {Object} props Component props. + * @param {Object} props.task The task object. + * @param {Function} props.onSubmit Callback when form is submitted. + * @param {Function} props.onClose Callback when popover is closed. + * @return {JSX.Element} The popover component. + */ + +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import FormErrorMessage from './FormErrorMessage'; +import SubmitButton from './SubmitButton'; +import { usePopoverSubmit } from '../../hooks/usePopoverSubmit'; +import { submitPluginSettings } from '../../hooks/usePopoverForms'; + +export default function AIOSEOPopover( { task, onSubmit, onClose } ) { + const { isLoading, error, handleSubmit } = usePopoverSubmit( async () => { + const popoverId = `prpl-popover-${ task.slug || task.id }`; + const taskId = task.slug || task.prpl_provider?.slug || task.id; + + // Get config from POPOVER_CONFIG + const configs = { + 'aioseo-author-archive': { + setting: 'aioseo_options', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'archives', + 'author', + 'show', + ] ), + settingCallbackValue: () => false, + }, + 'aioseo-date-archive': { + setting: 'aioseo_options', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'archives', + 'date', + 'show', + ] ), + settingCallbackValue: () => false, + }, + 'aioseo-media-pages': { + setting: 'aioseo_options_dynamic', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'postTypes', + 'attachment', + 'redirectAttachmentUrls', + ] ), + settingCallbackValue: () => 'attachment', + }, + 'aioseo-crawl-settings-feed-authors': { + setting: 'aioseo_options', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'advanced', + 'crawlCleanup', + 'feeds', + 'authors', + ] ), + settingCallbackValue: () => false, + }, + 'aioseo-crawl-settings-feed-comments': { + // This task needs to update TWO settings. + multiUpdate: true, + updates: [ + { + setting: 'aioseo_options', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'advanced', + 'crawlCleanup', + 'feeds', + 'globalComments', + ] ), + value: false, + }, + { + setting: 'aioseo_options', + settingPath: JSON.stringify( [ + 'searchAppearance', + 'advanced', + 'crawlCleanup', + 'feeds', + 'postComments', + ] ), + value: false, + }, + ], + }, + }; + + const config = configs[ taskId ]; + if ( config ) { + if ( config.multiUpdate && config.updates ) { + // Handle multi-update case (e.g., Feed Comments). + for ( const update of config.updates ) { + await submitPluginSettings( { + setting: update.setting, + settingPath: update.settingPath, + popoverId, + value: update.value, + } ); + } + } else { + await submitPluginSettings( { + setting: config.setting, + settingPath: config.settingPath, + popoverId, + settingCallbackValue: config.settingCallbackValue, + value: config.settingCallbackValue(), + } ); + } + } + + if ( onSubmit ) { + await onSubmit( task.id, task ); + } + }, [ task, onSubmit ] ); + + const taskTitle = decodeEntities( task.title?.rendered || task.title ); + const taskDescription = + task.description?.rendered || task.description || ''; + + return ( + +
    +

    { taskTitle }

    + { taskDescription &&

    { taskDescription }

    } +
    +
    +
    + +
    + +
    + +
    +
    + ); +} diff --git a/assets/src/components/Popovers/BadgeStreakPopover.js b/assets/src/components/Popovers/BadgeStreakPopover.js new file mode 100644 index 0000000000..15efc5ddc3 --- /dev/null +++ b/assets/src/components/Popovers/BadgeStreakPopover.js @@ -0,0 +1,241 @@ +/** + * Badge Streak Popover Component. + * + * Displays badge streak information with progress bars. + * + * @param {Object} props Component props. + * @param {Object} props.task The task object. + * @param {Function} props.onClose Callback when popover is closed. + * @return {JSX.Element} The popover component. + */ + +import { __, sprintf } from '@wordpress/i18n'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import PopoverLoadingState from './PopoverLoadingState'; +import { resolveTaskId } from '../../utils/taskIdResolver'; +import { useApiData } from '../../hooks/useApiData'; + +export default function BadgeStreakPopover( { task, onClose } ) { + const { data: badgeData, isLoading } = useApiData( + '/progress-planner/v1/badge-stats' + ); + const badgeStats = badgeData?.badges || ( badgeData ? {} : null ); + + /** + * Get badge progress for a category. + * + * @param {string} category The badge category (maintenance or content). + * @return {Object} Badge progress data. + */ + const getBadgeProgress = ( category ) => { + if ( ! badgeStats ) { + return null; + } + + // Find badges for this category + const categoryBadges = Object.keys( badgeStats ) + .filter( ( badgeId ) => badgeId.startsWith( category + '-' ) ) + .map( ( badgeId ) => ( { + id: badgeId, + ...badgeStats[ badgeId ], + } ) ) + .sort( ( a, b ) => { + // Sort by level (extract number from badge ID) + const aLevel = parseInt( a.id.match( /\d+/ )?.[ 0 ] || '0' ); + const bLevel = parseInt( b.id.match( /\d+/ )?.[ 0 ] || '0' ); + return aLevel - bLevel; + } ); + + if ( categoryBadges.length === 0 ) { + return null; + } + + // Get the last badge (highest level) + const lastBadge = categoryBadges[ categoryBadges.length - 1 ]; + const progress = lastBadge.progress || 0; + const remaining = lastBadge.remaining || 0; + + return { + badges: categoryBadges, + progress, + remaining, + }; + }; + + /** + * Render badge indicator. + * + * @param {Object} badge Badge data. + * @param {string} context Context (maintenance or content). + * @return {JSX.Element} Badge indicator. + */ + const renderBadgeIndicator = ( badge, context ) => { + const remaining = badge.remaining || 0; + + return ( +
    + + { remaining === 0 ? ( + '✔️' + ) : ( + { + let formatStr; + if ( context === 'content' ) { + formatStr = + remaining === 1 + ? /* translators: %s: number of posts remaining */ + '%s post to go' + : /* translators: %s: number of posts remaining */ + '%s posts to go'; + } else { + formatStr = + remaining === 1 + ? /* translators: %s: number of weeks remaining */ + '%s week to go' + : /* translators: %s: number of weeks remaining */ + '%s weeks to go'; + } + return sprintf( + formatStr, + sprintf( + '%s', + remaining + ) + ); + } )(), + } } + /> + ) } + +
    + ); + }; + + /** + * Render progress bar for a category. + * + * @param {string} category The badge category. + * @return {JSX.Element} Progress bar component. + */ + const renderProgressBar = ( category ) => { + const badgeProgress = getBadgeProgress( category ); + + if ( ! badgeProgress ) { + return null; + } + + const { progress, badges } = badgeProgress; + + return ( +
    + + + +
    + { badges.map( ( badge ) => + renderBadgeIndicator( badge, category ) + ) } +
    +
    + ); + }; + + const maintenanceProgress = getBadgeProgress( 'maintenance' ); + const contentProgress = getBadgeProgress( 'content' ); + const taskId = resolveTaskId( task, 'badge-streak' ); + + return ( + +
    +

    + { __( 'You are on the right track!', 'progress-planner' ) } +

    +

    + { __( + 'Find out which badges to unlock next and become a Progress Planner Professional!', + 'progress-planner' + ) } +

    +
    +
    +
    +
    +

    + { __( + "Don't break your streak and stay active every week!", + 'progress-planner' + ) } +

    +

    + { __( + 'Execute at least one website maintenance task every week. That could be publishing content, adding content, updating a post, or updating a plugin.', + 'progress-planner' + ) } +

    +

    + { __( + 'Not able to work on your site for a week? Use your streak freeze!', + 'progress-planner' + ) } +

    + { isLoading ? ( + + ) : ( +
    + { maintenanceProgress && ( +
    + { renderProgressBar( 'maintenance' ) } +
    + ) } +
    + ) } +
    + +
    +

    + { __( + 'Keep adding posts and pages', + 'progress-planner' + ) } +

    +

    + { __( + 'The more you write, the sooner you unlock new badges. You can earn level 1 of this badge immediately after installing the plugin if you have written 20 or more blog posts.', + 'progress-planner' + ) } +

    + { isLoading ? ( + + ) : ( +
    + { contentProgress && ( +
    + { renderProgressBar( 'content' ) } +
    + ) } +
    + ) } +
    +
    +
    +
    +

    { __( 'Streak freeze', 'progress-planner' ) }

    +

    + { __( + "Going on a holiday? Or don't have any time this week? You can skip your website maintenance for a maximum of one week. Your streak will continue afterward.", + 'progress-planner' + ) } +

    +
    +
    +
    +
    + ); +} diff --git a/assets/src/components/Popovers/BlogDescriptionPopover.js b/assets/src/components/Popovers/BlogDescriptionPopover.js new file mode 100644 index 0000000000..4ee5d5818c --- /dev/null +++ b/assets/src/components/Popovers/BlogDescriptionPopover.js @@ -0,0 +1,105 @@ +/** + * Blog Description Popover Component. + * + * Allows users to set the site tagline. + * + * @param {Object} props Component props. + * @param {Object} props.task The task object. + * @param {Function} props.onSubmit Callback when form is submitted. + * @param {Function} props.onClose Callback when popover is closed. + * @return {JSX.Element} The popover component. + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import FormErrorMessage from './FormErrorMessage'; +import SubmitButton from './SubmitButton'; +import { usePopoverSubmit } from '../../hooks/usePopoverSubmit'; +import { useWpSettings } from '../../hooks/useWpSettings'; +import { submitSiteSettings } from '../../hooks/usePopoverForms'; + +export default function BlogDescriptionPopover( { task, onSubmit, onClose } ) { + const [ value, setValue ] = useState( '' ); + const { settings } = useWpSettings( [ 'description' ] ); + + // Seed local state from fetched settings. + useEffect( () => { + if ( settings.description ) { + setValue( settings.description ); + } + }, [ settings.description ] ); + + const { isLoading, error, handleSubmit } = usePopoverSubmit( async () => { + if ( ! value.trim() ) { + return; + } + + const popoverId = `prpl-popover-${ task.slug || task.id }`; + await submitSiteSettings( { + settingAPIKey: 'description', + setting: 'blogdescription', + popoverId, + settingCallbackValue: () => value.trim(), + value: value.trim(), + } ); + + if ( onSubmit ) { + await onSubmit( task.id, task ); + } + }, [ value, task, onSubmit ] ); + + const taskTitle = decodeEntities( task.title?.rendered || task.title ); + const taskDescription = + task.description?.rendered || task.description || ''; + + return ( + +
    +

    { taskTitle }

    +

    + { __( + "In a few words, explain what this site is about. This information is used in your website's schema and RSS feeds, and can be displayed on your site. The tagline typically is your site's mission statement.", + 'progress-planner' + ) } +

    +
    +
    +
    + { taskDescription &&

    { taskDescription }

    } + + +
    + +
    + +
    +
    + ); +} diff --git a/assets/src/components/Popovers/CustomPopover.js b/assets/src/components/Popovers/CustomPopover.js new file mode 100644 index 0000000000..d435b0d15b --- /dev/null +++ b/assets/src/components/Popovers/CustomPopover.js @@ -0,0 +1,306 @@ +/** + * Custom Popover Component. + * + * Handles complex custom submit handlers with form inputs when needed. + * + * @param {Object} props Component props. + * @param {Object} props.task The task object. + * @param {Function} props.onSubmit Callback when form is submitted. + * @param {Function} props.onClose Callback when popover is closed. + * @param {Function} props.onCustomSubmit Custom submit handler from PopoverManager. + * @return {JSX.Element} The popover component. + */ + +import { useState, useCallback, useEffect } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; +import apiFetch from '@wordpress/api-fetch'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import FormErrorMessage from './FormErrorMessage'; +import SubmitButton from './SubmitButton'; +import { getAjaxUrl, getNonce } from '../../config/dashboardConfig'; + +export default function CustomPopover( { + task, + onSubmit, + onClose, + onCustomSubmit, +} ) { + const taskId = task.slug || task.prpl_provider?.slug || task.id; + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + + // Form state for rename-uncategorized-category + const [ categoryName, setCategoryName ] = useState( '' ); + const [ categorySlug, setCategorySlug ] = useState( '' ); + + // Form state for update-term-description + const [ termDescription, setTermDescription ] = useState( '' ); + const [ termId, setTermId ] = useState( null ); + const [ taxonomy, setTaxonomy ] = useState( '' ); + + /** + * Load initial data for specific tasks. + */ + useEffect( () => { + if ( taskId === 'update-term-description' ) { + // Get term data from task + const targetTermId = + task.target_term_id || task.prpl_task_data?.target_term_id; + const targetTaxonomy = + task.target_taxonomy || task.prpl_task_data?.target_taxonomy; + + if ( targetTermId && targetTaxonomy ) { + setTermId( targetTermId ); + setTaxonomy( targetTaxonomy ); + + // Fetch current term description + const endpoint = + targetTaxonomy === 'category' + ? 'categories' + : targetTaxonomy; + apiFetch( { path: `/wp/v2/${ endpoint }/${ targetTermId }` } ) + .then( ( term ) => { + setTermDescription( term.description || '' ); + } ) + .catch( () => { + // Ignore errors + } ); + } + } else if ( taskId === 'rename-uncategorized-category' ) { + // Fetch current category name + const categoryId = + task.prpl_task_data?.category_id || task.category_id; + if ( categoryId ) { + apiFetch( { path: `/wp/v2/categories/${ categoryId }` } ) + .then( ( category ) => { + setCategoryName( category.name || '' ); + setCategorySlug( category.slug || '' ); + } ) + .catch( () => { + // Ignore errors, use defaults + setCategoryName( + __( 'Uncategorized', 'progress-planner' ) + ); + } ); + } + } + }, [ taskId, task ] ); + + /** + * Handle form submission. + */ + const handleSubmit = useCallback( + async ( e ) => { + e.preventDefault(); + + setIsLoading( true ); + setError( null ); + + try { + const popoverId = `prpl-popover-${ taskId }`; + + if ( taskId === 'rename-uncategorized-category' ) { + // Submit category rename via AJAX + const ajaxUrl = getAjaxUrl(); + const nonce = getNonce(); + + const body = new URLSearchParams( { + action: 'prpl_interactive_task_submit_rename-uncategorized-category', + _ajax_nonce: nonce, + uncategorized_category_name: categoryName.trim(), + uncategorized_category_slug: categorySlug.trim(), + } ); + + const response = await fetch( ajaxUrl, { + method: 'POST', + body, + credentials: 'same-origin', + } ); + + const data = await response.json(); + + if ( ! data.success ) { + throw new Error( + data.data?.message || + __( + 'Failed to update category.', + 'progress-planner' + ) + ); + } + } else if ( taskId === 'update-term-description' ) { + // Submit term description via AJAX + const ajaxUrl = getAjaxUrl(); + const nonce = getNonce(); + + const body = new URLSearchParams( { + action: 'prpl_interactive_task_submit_update-term-description', + _ajax_nonce: nonce, + term_id: termId, + taxonomy, + description: termDescription, + } ); + + const response = await fetch( ajaxUrl, { + method: 'POST', + body, + credentials: 'same-origin', + } ); + + const data = await response.json(); + + if ( ! data.success ) { + throw new Error( + data.data?.message || + __( + 'Failed to update term description.', + 'progress-planner' + ) + ); + } + } else if ( onCustomSubmit ) { + // For other tasks, use the custom submit handler + await onCustomSubmit( taskId, popoverId ); + } + + if ( onSubmit ) { + await onSubmit( task.id, task ); + } + } catch ( err ) { + setError( + err.message || + __( + 'Something went wrong. Please try again.', + 'progress-planner' + ) + ); + } finally { + setIsLoading( false ); + } + }, + [ + taskId, + task, + onSubmit, + onCustomSubmit, + categoryName, + categorySlug, + termId, + taxonomy, + termDescription, + ] + ); + + const taskTitle = decodeEntities( task.title?.rendered || task.title ); + const taskDescription = + task.description?.rendered || task.description || ''; + + // Render form inputs based on task type + const renderFormInputs = () => { + if ( taskId === 'rename-uncategorized-category' ) { + return ( + <> + + + + ); + } + + if ( taskId === 'update-term-description' ) { + const termName = + task.target_term_name || + task.prpl_task_data?.target_term_name || + ''; + return ( + <> + { termName && ( +

    + { __( 'Term:', 'progress-planner' ) }{ ' ' } + { termName } +

    + ) } +
    - -
    - - -
    - - -
    - -
  • - - - - - $reasons, + 'remoteServerUrl' => \progress_planner()->get_remote_server_root_url(), + 'deactivateUrl' => \wp_nonce_url( 'plugins.php?action=deactivate&plugin=' . \rawurlencode( \plugin_basename( \constant( 'PROGRESS_PLANNER_FILE' ) ) ), 'deactivate-plugin_' . \plugin_basename( \constant( 'PROGRESS_PLANNER_FILE' ) ) ), + 'pluginSlug' => self::PLUGIN_SLUG, + 'siteUrl' => \esc_attr( \get_site_url() ), + ] + ); + + // Render mount container. + echo '
    '; } } diff --git a/classes/class-plugin-installer.php b/classes/class-plugin-installer.php deleted file mode 100644 index 613623e7f5..0000000000 --- a/classes/class-plugin-installer.php +++ /dev/null @@ -1,298 +0,0 @@ -check_capabilities(); - if ( ! $can_install ) { - \wp_die( \esc_html( $can_install ) ); - } - - // Check the nonce. - \check_ajax_referer( 'progress_planner', 'nonce' ); - - // Get the plugin slug from the request. - $slug = isset( $_POST['plugin_slug'] ) - ? \sanitize_text_field( \wp_unslash( $_POST['plugin_slug'] ) ) - : ''; - - // If the plugin slug is empty, return an error. - if ( empty( $slug ) ) { - \wp_send_json_error( - [ - 'code' => 'empty_plugin_slug', - 'message' => \esc_attr__( 'An Error Occured', 'progress-planner' ), - ] - ); - } - - // If the plugin is already installed, return a success message. - if ( $this->is_plugin_installed( $slug ) ) { - \wp_send_json_success( - [ - 'code' => 'plugin_already_installed', - 'message' => \esc_html__( 'Plugin already installed', 'progress-planner' ), - ] - ); - } - - // Install the plugin. - $installed = $this->install_plugin( $slug ); - - // If the plugin is installed, return a success message. - if ( $installed && ! \is_wp_error( $installed ) ) { - \wp_send_json_success( - [ - 'code' => 'plugin_installed', - 'message' => \esc_html__( 'Plugin installed', 'progress-planner' ), - ] - ); - } - - // If the plugin is not installed, return an error message. - \wp_send_json_error( - [ - 'code' => 'install_failed', - 'message' => \esc_html__( 'An Error Occured', 'progress-planner' ), - ] - ); - } - - /** - * Tries to activate the plugin - * - * @return void - */ - public function activate() { - // Check if the user has the necessary capabilities. - $can_activate = $this->check_capabilities(); - if ( ! $can_activate ) { - \wp_die( \esc_html( $can_activate ) ); - } - - // Check the nonce. - \check_ajax_referer( 'progress_planner', 'nonce' ); - - // Get the plugin slug from the request. - $plugin_slug = isset( $_POST['plugin_slug'] ) - ? \sanitize_text_field( \wp_unslash( $_POST['plugin_slug'] ) ) - : ''; - - // If the plugin slug is empty, return an error. - if ( empty( $plugin_slug ) ) { - \wp_send_json_error( - [ - 'code' => 'empty_plugin_slug', - 'message' => \esc_attr__( 'An Error Occured', 'progress-planner' ), - ] - ); - } - - // Get the plugin path. - $plugin_path = ''; - foreach ( \array_keys( \get_plugins() ) as $plugin ) { - if ( \explode( '/', $plugin )[0] === $plugin_slug ) { - $plugin_path = $plugin; - break; - } - } - - // If the plugin path is empty, return an error. - if ( empty( $plugin_path ) ) { - \wp_send_json_error( - [ - 'code' => 'plugin_not_found', - 'message' => \esc_attr__( 'An Error Occured', 'progress-planner' ), - ] - ); - } - - // Activate the plugin. - $activated = \activate_plugin( $plugin_path ); - - // If the plugin is not activated, return an error message. - if ( \is_wp_error( $activated ) ) { - \wp_send_json_error( - [ - 'code' => 'activate_failed', - 'message' => \esc_attr__( 'An Error Occured', 'progress-planner' ), - ] - ); - } - - // If the plugin is activated, return a success message. - \wp_send_json_success( - [ - 'code' => 'plugin_activated', - 'message' => \esc_html__( 'Plugin activated', 'progress-planner' ), - ] - ); - } - - /** - * Install a plugin. - * - * @param string $plugin The plugin to install. - * - * @return bool|\WP_Error - */ - private function install_plugin( $plugin ) { - // Include the necessary files. - require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; // @phpstan-ignore-line - require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; // @phpstan-ignore-line - require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php'; // @phpstan-ignore-line - require_once ABSPATH . 'wp-admin/includes/class-plugin-installer-skin.php'; // @phpstan-ignore-line - - // Get the plugin information. - $api = \plugins_api( - 'plugin_information', - [ - 'slug' => $plugin, - 'fields' => [ - 'sections' => false, - ], - ] - ); - - // If the plugin information is not found, return an error. - if ( \is_wp_error( $api ) ) { - \wp_die( $api ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - - $api = (object) $api; - // If the plugin download link is not found, return an error. - if ( ! isset( $api->download_link ) ) { - return new \WP_Error( 'no_download_link', \__( 'No download link found', 'progress-planner' ) ); - } - - // If the plugin name is not found, return an error. - if ( ! isset( $api->name ) ) { - return new \WP_Error( 'no_name', \__( 'No name found', 'progress-planner' ) ); - } - - // If the plugin version is not found, return an error. - if ( ! isset( $api->version ) ) { - return new \WP_Error( 'no_version', \__( 'No version found', 'progress-planner' ) ); - } - - // Create a new plugin upgrader. - $upgrader = new \Plugin_Upgrader( - new \Plugin_Installer_Skin( - [ - 'type' => 'web', - /* translators: %s: Plugin name and version. */ - 'title' => \sprintf( \__( 'Installing Plugin: %s', 'progress-planner' ), $api->name . ' ' . $api->version ), - 'url' => 'update.php?action=install-plugin&plugin=' . \rawurlencode( $plugin ), - 'nonce' => 'install-plugin_' . $plugin, - 'plugin' => $plugin, - 'api' => $api, - ] - ) - ); - - // Install the plugin. - return $upgrader->install( $api->download_link ); - } - - /** - * Check if the user is allowed to install the plugin. - * - * @return string|true Error message, or true if the user is allowed to install the plugin. - */ - public function check_capabilities() { - return \current_user_can( 'install_plugins' ) - ? true - : \esc_html__( 'Sorry, you are not allowed to install plugins on this site.', 'progress-planner' ); - } - - /** - * Checks if plugin is intalled - * - * @param string $plugin_slug The slug of the plugin we want to install. - * - * @return bool - */ - public function is_plugin_installed( $plugin_slug ) { - return ! empty( $this->get_plugin_path( $plugin_slug ) ); - } - - /** - * Checks if plugin is activated - * - * @param string $plugin_slug The slug of the plugin we want to install. - * - * @return bool - */ - public function is_plugin_activated( $plugin_slug ) { - // Get the plugin path. - $plugin_path = $this->get_plugin_path( $plugin_slug ); - - // If the plugin path is empty, return false. - if ( empty( $plugin_path ) ) { - return false; - } - - // If the is_plugin_active function does not exist, include the necessary file. - if ( ! \function_exists( 'is_plugin_active' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore-line - } - - // Return if the plugin is activated. - return \is_plugin_active( $plugin_path ); - } - - /** - * Get the path of the plugin - * - * @param string $plugin_slug The slug of the plugin we want to install. - * - * @return string - */ - private function get_plugin_path( $plugin_slug ) { - // If the plugin slug is empty, return an empty string. - if ( empty( $plugin_slug ) ) { - return ''; - } - - // If the get_plugins function does not exist, include the necessary file. - if ( ! \function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore-line - } - - // Return the plugin path. - foreach ( \array_keys( \get_plugins() ) as $plugin ) { - if ( \explode( '/', $plugin )[0] === $plugin_slug ) { - return $plugin; - } - } - - // If the plugin path is not found, return an empty string. - return ''; - } -} diff --git a/classes/class-plugin-upgrade-tasks.php b/classes/class-plugin-upgrade-tasks.php index a111e79065..2322de6cae 100644 --- a/classes/class-plugin-upgrade-tasks.php +++ b/classes/class-plugin-upgrade-tasks.php @@ -23,7 +23,7 @@ public function __construct() { \add_action( 'progress_planner_plugin_updated', [ $this, 'plugin_updated' ], 10 ); // Check if the plugin was upgraded or new plugin was activated. - \add_action( 'init', [ $this, 'handle_activation_or_upgrade' ], 100 ); // We need to run this after the Local_Tasks_Manager::init() is called. + \add_action( 'init', [ $this, 'handle_activation_or_upgrade' ], 100 ); // Add the action to add the upgrade tasks popover. \add_action( 'progress_planner_admin_page_after_widgets', [ $this, 'add_upgrade_tasks_popover' ] ); @@ -139,31 +139,12 @@ public function maybe_add_onboarding_tasks() { /** * Get the newly added task providers. * - * @return array + * @deprecated Task providers are now handled by React. This method returns empty array. + * + * @return array Always returns empty array. */ public function get_newly_added_task_providers() { - static $newly_added_task_providers; - - if ( ! $this->should_show_upgrade_popover() ) { - return []; - } - - if ( null === $newly_added_task_providers ) { - $task_provider_ids = $this->get_upgrade_popover_task_provider_ids(); - - $task_providers = []; - - foreach ( $task_provider_ids as $task_provider_id ) { - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_provider_id ); // @phpstan-ignore-line method.nonObject - if ( $task_provider ) { // @phpstan-ignore-line - $task_providers[] = $task_provider; - } - } - - $newly_added_task_providers = $task_providers; - } - - return $newly_added_task_providers; + return []; } /** @@ -200,7 +181,39 @@ public function delete_upgrade_popover_task_providers() { */ public function add_upgrade_tasks_popover() { if ( $this->should_show_upgrade_popover() ) { - \progress_planner()->get_ui__popover()->the_popover( 'upgrade-tasks' )->render(); + // Popover is now handled by React via PopoverManager. + // Data is fetched via REST API when popover opens. + // Trigger popover via WordPress hook after a short delay to ensure React is loaded. + \add_action( 'admin_footer', [ $this, 'trigger_upgrade_tasks_popover' ], 999 ); } } + + /** + * Trigger upgrade tasks popover via WordPress hook. + * + * @return void + */ + public function trigger_upgrade_tasks_popover() { + // Delete upgrade popover task providers when popover is shown. + // This matches the original PHP template behavior. + $this->delete_upgrade_popover_task_providers(); + + // Create a task object for the popover. + $task = [ + 'id' => 'upgrade-tasks', + 'slug' => 'upgrade-tasks', + 'title' => \__( "We've added new recommendations to the Progress Planner plugin", 'progress-planner' ), + ]; + + // Trigger popover via WordPress hook. + ?> + + 'future', ]; - /** - * An object containing tasks. - * - * @var \Progress_Planner\Suggested_Tasks\Tasks_Manager - */ - private Tasks_Manager $tasks_manager; - /** * Constructor. */ public function __construct() { - $this->tasks_manager = new Tasks_Manager(); - if ( \is_admin() ) { - \add_action( 'admin_init', [ $this, 'init' ], 20 ); // Wait for the post types to be initialized and transients to be set. - // Check GET parameter and maybe set task as pending. \add_action( 'init', [ $this, 'maybe_complete_task' ] ); } @@ -70,28 +57,6 @@ public function __construct() { \add_filter( 'wp_trash_post_days', [ $this, 'change_trashed_posts_lifetime' ], 10, 2 ); } - /** - * Run the tasks. - * - * @return void - */ - public function init(): void { - // Check for completed tasks. - $completed_tasks = $this->tasks_manager->evaluate_tasks(); - - foreach ( $completed_tasks as $task ) { - if ( ! $task->post_name && $task->ID ) { - continue; - } - - // Change the task status to pending. - $task->celebrate(); - - // Insert an activity. - $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ); - } - } - /** * Insert an activity. * @@ -159,15 +124,6 @@ public function on_automatic_updates_complete(): void { $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $pending_tasks[0]->post_name ) ); } - /** - * Get the tasks manager. - * - * @return \Progress_Planner\Suggested_Tasks\Tasks_Manager - */ - public function get_tasks_manager(): Tasks_Manager { - return $this->tasks_manager; - } - /** * Check if a task was completed. Task is considered completed if it was trashed or pending. * @@ -330,15 +286,8 @@ public function suggested_task_action() { \wp_send_json_error( [ 'message' => \esc_html__( 'Task not found.', 'progress-planner' ) ] ); } - $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' ) ] ); - } - - if ( ! $provider->capability_required() ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to complete this task.', 'progress-planner' ) ] ); - } + // Capability is checked client-side before task was shown, + // and user must be logged in with edit_others_posts to reach this endpoint. $updated = false; @@ -467,7 +416,7 @@ public function register_taxonomy() { public function rest_api_tax_query( $args, $request ) { $tax_query = []; - // Exclude terms. + // Exclude providers. if ( isset( $request['exclude_provider'] ) ) { $tax_query[] = [ 'taxonomy' => 'prpl_recommendations_provider', @@ -477,26 +426,19 @@ public function rest_api_tax_query( $args, $request ) { ]; } - $include_providers = []; - $providers_available_for_user = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_providers_available_for_user(); - foreach ( $providers_available_for_user as $provider ) { - $include_providers[] = $provider->get_provider_id(); - } - - // Include terms (matches any term in list). + // Include specific providers if requested. if ( isset( $request['provider'] ) ) { - $request_providers = \explode( ',', $request['provider'] ); - $include_providers = \array_intersect( $include_providers, $request_providers ); + $tax_query[] = [ + 'taxonomy' => 'prpl_recommendations_provider', + 'field' => 'slug', + 'terms' => \explode( ',', $request['provider'] ), + 'operator' => 'IN', + ]; } - $tax_query[] = [ - 'taxonomy' => 'prpl_recommendations_provider', - 'field' => 'slug', - 'terms' => $include_providers, - 'operator' => 'IN', - ]; - - $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + if ( ! empty( $tax_query ) ) { + $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + } // Handle sorting parameters. if ( isset( $request['filter']['orderby'] ) ) { @@ -526,41 +468,13 @@ public function rest_prepare_recommendation( $response, $post ) { if ( ! isset( $response->data['meta'] ) ) { $response->data['meta'] = []; } - $provider = false; - if ( $provider_term && ! \is_wp_error( $provider_term ) ) { - $provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $provider_term[0]->slug ); - } - $response->data['slug'] = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $response->data['slug'] ); - - if ( $provider ) { + if ( $provider_term && ! \is_wp_error( $provider_term ) ) { + // Set prpl_provider from the term so React can identify the provider and generate actions client-side. $response->data['prpl_provider'] = $provider_term[0]; - // Link should be added during run time, since it is not added for users without required capability. - $response->data['meta']['prpl_url'] = $response->data['meta']['prpl_url'] && $provider->capability_required() - ? \esc_url( (string) $response->data['meta']['prpl_url'] ) - : ''; - - $response->data['prpl_popover_id'] = $provider->get_popover_id(); - $response->data['prpl_points'] = $provider->get_points(); - - /* - * Check if task was completed before - for example, comments were disabled and then re-enabled, and remove points if so. - * Those are tasks which are completed by toggling an option, so non repetitive & not user tasks. - */ - if ( ! \has_term( 'user', 'prpl_recommendations_provider', $post->ID ) && ! $provider->is_repetitive() && $provider->task_has_activity( $response->data['slug'] ) ) { - $response->data['prpl_points'] = 0; - } - - // Assign point only to golden user task. - if ( 'user' === $provider->get_provider_id() ) { - $response->data['prpl_points'] = ( ! empty( $post->post_excerpt ) && \str_contains( $post->post_excerpt, 'GOLDEN' ) ) ? 1 : 0; - } - - // This has to be the last item to be added because actions use data from previous items. - $response->data['prpl_task_actions'] = $provider->get_task_actions( $response->data ); } - // Category taxonomy removed - no longer adding prpl_category to response. + $response->data['slug'] = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $response->data['slug'] ); return $response; } diff --git a/classes/goals/class-goal-recurring.php b/classes/goals/class-goal-recurring.php index 6a5cb3e822..6ca6bebb47 100644 --- a/classes/goals/class-goal-recurring.php +++ b/classes/goals/class-goal-recurring.php @@ -182,8 +182,9 @@ public function get_streak() { $occurences = $this->get_occurences(); // Initialize streak counters. - $streak_nr = 0; // Current ongoing streak. - $max_streak = 0; // Best streak ever achieved. + $streak_nr = 0; // Current ongoing streak. + $max_streak = 0; // Best streak ever achieved. + $remaining_breaks = $this->allowed_break; // Local copy to avoid mutating instance property. foreach ( $occurences as $occurence ) { // Check if this occurrence's goal was met. @@ -197,10 +198,10 @@ public function get_streak() { } // Goal was not met: Check if we can use an allowed break. - if ( $this->allowed_break > 0 ) { + if ( $remaining_breaks > 0 ) { // Use one allowed break to keep the streak alive. // This prevents the streak from resetting for this missed goal. - --$this->allowed_break; + --$remaining_breaks; continue; } diff --git a/classes/rest/class-activities.php b/classes/rest/class-activities.php new file mode 100644 index 0000000000..1980e05e14 --- /dev/null +++ b/classes/rest/class-activities.php @@ -0,0 +1,113 @@ + 'GET', + 'callback' => [ $this, 'get_activities' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'edit_posts' ); + } + + /** + * Get activities data. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_activities( $request ) { + $query = \progress_planner()->get_activities__query(); + + // Get all activities (we'll filter in React). + $activities = $query->query_activities( [] ); + + // Convert activities to array format. + $activities_data = []; + foreach ( $activities as $activity ) { + $activity_data = [ + 'id' => $activity->id, + 'date' => $activity->date ? $activity->date->format( 'Y-m-d H:i:s' ) : null, + 'category' => $activity->category, + 'type' => $activity->type, + 'data_id' => $activity->data_id, + 'user_id' => $activity->user_id, + ]; + + // For suggested_task activities, get points. + if ( $activity->category === 'suggested_task' ) { + $activity_date = $activity->date ? $activity->date : new \DateTime(); + $activity_data['points'] = $activity->get_points( $activity_date ); + } + + $activities_data[] = $activity_data; + } + + // Get total posts count for content badges. + $total_posts_count = 0; + $post_types = \progress_planner() + ->get_activities__content_helpers() + ->get_post_types_names(); + foreach ( $post_types as $post_type ) { + $counts = \wp_count_posts( $post_type ); + if ( isset( $counts->publish ) ) { + $total_posts_count += (int) $counts->publish; + } + } + + // Get activation date. + $activation_date = \progress_planner()->get_activation_date(); + + // Get config (branding, URLs). + $branding_id = (int) \progress_planner()->get_ui__branding()->get_branding_id(); + $remote_server_url = \progress_planner()->get_remote_server_root_url(); + $placeholder_url = \progress_planner()->get_placeholder_svg( 200, 200 ); + + // Build response data. + $response_data = [ + 'activities' => $activities_data, + 'totalPostsCount' => $total_posts_count, + 'activationDate' => $activation_date->format( 'Y-m-d' ), + 'config' => [ + 'brandingId' => $branding_id, + 'remoteServerUrl' => $remote_server_url, + 'placeholderUrl' => $placeholder_url, + ], + ]; + + return new \WP_REST_Response( $response_data ); + } +} diff --git a/classes/rest/class-badge-stats.php b/classes/rest/class-badge-stats.php new file mode 100644 index 0000000000..09b5ca938f --- /dev/null +++ b/classes/rest/class-badge-stats.php @@ -0,0 +1,150 @@ + 'GET', + 'callback' => [ $this, 'get_badge_stats' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + [ + 'methods' => 'POST', + 'callback' => [ $this, 'save_badge_stats' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'badges' => [ + 'required' => true, + 'type' => 'object', + 'validate_callback' => [ $this, 'validate_badge_stats' ], + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'edit_posts' ); + } + + /** + * Validate badge stats data. + * + * @param mixed $badges Badge stats data. + * + * @return bool + */ + public function validate_badge_stats( $badges ) { + if ( ! \is_array( $badges ) ) { + return false; + } + + foreach ( $badges as $badge_id => $badge_data ) { + if ( ! \is_string( $badge_id ) ) { + return false; + } + if ( ! \is_array( $badge_data ) ) { + return false; + } + // Validate required fields. + if ( ! isset( $badge_data['progress'] ) || ! \is_numeric( $badge_data['progress'] ) ) { + return false; + } + if ( ! isset( $badge_data['remaining'] ) || ! \is_numeric( $badge_data['remaining'] ) ) { + return false; + } + } + + return true; + } + + /** + * Get badge stats. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_badge_stats( $request ) { + $settings = \progress_planner()->get_settings(); + $badges = $settings->get( 'badges', [] ); + + return new \WP_REST_Response( [ 'badges' => $badges ] ); + } + + /** + * Save badge stats. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function save_badge_stats( $request ) { + $badges_data = $request->get_param( 'badges' ); + + if ( ! \is_array( $badges_data ) ) { + return new \WP_Error( + 'invalid_data', + \__( 'Invalid badge data.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + $settings = \progress_planner()->get_settings(); + $existing = $settings->get( 'badges', [] ); + + // Merge with existing data, preserving completion dates for completed badges. + foreach ( $badges_data as $badge_id => $badge_data ) { + // If badge is being marked as complete (100%), set completion date. + if ( + isset( $badge_data['progress'] ) && + 100 === (int) $badge_data['progress'] && + ( ! isset( $existing[ $badge_id ]['progress'] ) || + 100 !== (int) $existing[ $badge_id ]['progress'] ) + ) { + $badge_data['date'] = ( new \DateTime() )->format( 'Y-m-d H:i:s' ); + } elseif ( isset( $existing[ $badge_id ]['date'] ) ) { + // Preserve existing completion date. + $badge_data['date'] = $existing[ $badge_id ]['date']; + } + + $existing[ $badge_id ] = $badge_data; + } + + $settings->set( 'badges', $existing ); + + return new \WP_REST_Response( + [ + 'success' => true, + 'badges' => $existing, + ] + ); + } +} diff --git a/classes/rest/class-data-collectors.php b/classes/rest/class-data-collectors.php new file mode 100644 index 0000000000..41f1e67b22 --- /dev/null +++ b/classes/rest/class-data-collectors.php @@ -0,0 +1,149 @@ +[a-zA-Z0-9_-]+)', + [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_collector_data' ], + 'permission_callback' => [ $this, 'permission_callback' ], + 'args' => [ + 'collector_id' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + ], + ], + ] + ); + } + + /** + * Permission callback for data collector endpoint. + * + * @return bool + */ + public function permission_callback() { + return \current_user_can( 'edit_others_posts' ); + } + + /** + * Get data collector data. + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response|\WP_Error The REST response object or error. + */ + public function get_collector_data( $request ) { + $collector_id = $request->get_param( 'collector_id' ); + + if ( ! \is_string( $collector_id ) ) { + return new \WP_Error( + 'rest_invalid_collector_id', + \esc_html__( 'Invalid collector ID.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + // Map collector IDs to data collector classes. + $collector_map = $this->get_collector_map(); + + if ( ! isset( $collector_map[ $collector_id ] ) ) { + return new \WP_Error( + 'rest_collector_not_found', + \esc_html__( 'Data collector not found.', 'progress-planner' ), + [ 'status' => 404 ] + ); + } + + $collector_class = $collector_map[ $collector_id ]; + // @phpstan-ignore-next-line -- Array key access is validated above. + + if ( ! \class_exists( $collector_class ) ) { + return new \WP_Error( + 'rest_collector_class_not_found', + \esc_html__( 'Data collector class not found.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + // Instantiate and collect data. + $collector = new $collector_class(); + // @phpstan-ignore-next-line -- Dynamic class instantiation. + $data = $collector->collect(); + + return new \WP_REST_Response( + [ + 'collector_id' => $collector_id, + 'data' => $data, + ], + 200 + ); + } + + /** + * Get the map of collector IDs to class names. + * + * Maps collector IDs (kebab-case) to data collector class names. + * Collector IDs should match the DATA_KEY constant values (converted to kebab-case). + * + * @return array Array of collector_id => class_name mappings. + */ + protected function get_collector_map() { + $map = [ + 'hello_world_post_id' => \Progress_Planner\Suggested_Tasks\Data_Collector\Hello_World::class, + 'sample_page_id' => \Progress_Planner\Suggested_Tasks\Data_Collector\Sample_Page::class, + 'inactive_plugins_count' => \Progress_Planner\Suggested_Tasks\Data_Collector\Inactive_Plugins::class, + 'uncategorized_category_id' => \Progress_Planner\Suggested_Tasks\Data_Collector\Uncategorized_Category::class, + 'post_author_count' => \Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author::class, + 'last_published_post_id' => \Progress_Planner\Suggested_Tasks\Data_Collector\Last_Published_Post::class, + 'archive_format_count' => \Progress_Planner\Suggested_Tasks\Data_Collector\Archive_Format::class, + 'terms_without_posts' => \Progress_Planner\Suggested_Tasks\Data_Collector\Terms_Without_Posts::class, + 'terms_without_description' => \Progress_Planner\Suggested_Tasks\Data_Collector\Terms_Without_Description::class, + 'post_tag_count' => \Progress_Planner\Suggested_Tasks\Data_Collector\Post_Tag_Count::class, + 'published_post_count' => \Progress_Planner\Suggested_Tasks\Data_Collector\Published_Post_Count::class, + 'unpublished_content' => \Progress_Planner\Suggested_Tasks\Data_Collector\Unpublished_Content::class, + 'seo_plugin_installed' => \Progress_Planner\Suggested_Tasks\Data_Collector\SEO_Plugin::class, + 'php_version' => \Progress_Planner\Suggested_Tasks\Data_Collector\PHP_Version::class, + 'wp_debug_status' => \Progress_Planner\Suggested_Tasks\Data_Collector\WP_Debug::class, + 'old_posts_for_review' => \Progress_Planner\Suggested_Tasks\Data_Collector\Old_Posts_For_Review::class, + 'permalink_has_date' => \Progress_Planner\Suggested_Tasks\Data_Collector\Permalink_Has_Date::class, + 'yoast_options' => \Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Options::class, + 'yoast_premium_status' => \Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Premium_Status::class, + 'yoast_orphaned_content' => \Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Orphaned_Content::class, + 'aioseo_options' => \Progress_Planner\Suggested_Tasks\Data_Collector\AIOSEO_Options::class, + ]; + + /** + * Filter the data collector map. + * + * Allows third-party plugins to register custom data collectors. + * + * @param array $map Array of collector_id => class_name mappings. + * @return array Modified map. + */ + return \apply_filters( 'progress_planner_data_collector_map', $map ); + } +} diff --git a/classes/rest/class-email-sending-config.php b/classes/rest/class-email-sending-config.php new file mode 100644 index 0000000000..75389833aa --- /dev/null +++ b/classes/rest/class-email-sending-config.php @@ -0,0 +1,120 @@ + 'GET', + 'callback' => [ $this, 'get_config' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get email sending configuration. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_config( $request ) { + // Get current user for default email. + $current_user = \wp_get_current_user(); + $default_email = $current_user->user_email ?? ''; + + // Get troubleshooting guide URL. + $troubleshooting_guide_url = \esc_url( + \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/troubleshoot-smtp' ) + ); + + // Check if there's an email override (SMTP plugin). + $has_email_override = $this->is_there_sending_email_override(); + + // Get email subject. + $email_subject = \esc_html__( 'Your Progress Planner test message!', 'progress-planner' ); + + return new \WP_REST_Response( + [ + 'email_subject' => $email_subject, + 'troubleshooting_guide_url' => $troubleshooting_guide_url, + 'has_email_override' => $has_email_override, + 'default_email' => $default_email, + ], + 200 + ); + } + + /** + * Check if there's an email sending override (SMTP plugin). + * + * @return bool + */ + private function is_there_sending_email_override() { + return $this->is_wp_mail_filtered() || $this->is_wp_mail_overridden(); + } + + /** + * Check if wp_mail is filtered (phpmailer_init or pre_wp_mail hooks have callbacks). + * + * @return bool + */ + private function is_wp_mail_filtered() { + global $wp_filter; + + foreach ( [ 'phpmailer_init', 'pre_wp_mail' ] as $filter ) { + if ( isset( $wp_filter[ $filter ] ) && $wp_filter[ $filter ] instanceof \WP_Hook && ! empty( $wp_filter[ $filter ]->callbacks ) ) { + return true; + } + } + + return false; + } + + /** + * Check if wp_mail function has been overridden by a plugin. + * + * @return bool + */ + private function is_wp_mail_overridden() { + // Just in case, since it will trigger PHP fatal error if the function doesn't exist. + if ( ! \function_exists( 'wp_mail' ) ) { + return false; + } + + $file_path = ( new \ReflectionFunction( 'wp_mail' ) )->getFileName(); + + return $file_path && $file_path !== ABSPATH . 'wp-includes/pluggable.php'; + } +} diff --git a/classes/rest/class-email-test.php b/classes/rest/class-email-test.php new file mode 100644 index 0000000000..128bfb2a05 --- /dev/null +++ b/classes/rest/class-email-test.php @@ -0,0 +1,125 @@ + 'POST', + 'callback' => [ $this, 'test_email' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'email_address' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'validate_callback' => function ( $param ) { + return \is_email( $param ); + }, + ], + 'task_id' => [ + 'required' => false, + 'type' => 'string', + 'default' => '', + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Test email sending. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function test_email( $request ) { + $email_address = $request->get_param( 'email_address' ); + $task_id = $request->get_param( 'task_id' ); + + if ( ! \is_email( $email_address ) ) { + return new \WP_Error( + 'invalid_email', + \esc_html__( 'Invalid email address.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + // Get task ID from request or use default. + $task_id = ! empty( $task_id ) ? $task_id : 'sending-email'; + + // Generate a secure token for the completion link to prevent CSRF. + $user_id = \get_current_user_id(); + $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $task_id, $user_id ); + + $email_subject = \esc_html__( 'Your Progress Planner test message!', 'progress-planner' ); + $email_content = \sprintf( + // translators: %1$s the admin URL, %2$s the assistant name. + \__( 'You just used Progress Planner to verify if sending email works on your website.

    The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), + \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $task_id . '&token=' . $token ), + \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) + ); + + $headers = [ 'Content-Type: text/html; charset=UTF-8' ]; + + // Track email errors. + $email_error = null; + \add_action( + 'wp_mail_failed', + function ( $error ) use ( &$email_error ) { + $email_error = $error->get_error_message() ? $error->get_error_message() : \esc_html__( 'Unknown error', 'progress-planner' ); + } + ); + + $result = \wp_mail( $email_address, $email_subject, $email_content, $headers ); + + if ( $result ) { + return new \WP_REST_Response( + [ + 'success' => true, + 'message' => \esc_html__( 'Email sent successfully.', 'progress-planner' ), + ], + 200 + ); + } + + // If email failed, return the error. + $error_message = $email_error ? $email_error : \esc_html__( 'Unknown error', 'progress-planner' ); + return new \WP_Error( + 'email_failed', + $error_message, + [ 'status' => 500 ] + ); + } +} diff --git a/classes/rest/class-locale-options.php b/classes/rest/class-locale-options.php new file mode 100644 index 0000000000..c5d455904e --- /dev/null +++ b/classes/rest/class-locale-options.php @@ -0,0 +1,112 @@ + 'GET', + 'callback' => [ $this, 'get_locale_options' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get locale options. + * + * @return \WP_REST_Response + */ + public function get_locale_options() { + $options = $this->build_locale_options(); + + return new \WP_REST_Response( $options, 200 ); + } + + /** + * Build locale options array. + * + * Uses WordPress's get_available_languages() and wp_get_available_translations() + * to build the list of available locales. + * + * @return array Array of locale option objects with 'value' and 'label' properties. + */ + private function build_locale_options() { + if ( ! \function_exists( 'request_filesystem_credentials' ) ) { + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + if ( ! \function_exists( 'wp_get_available_translations' ) ) { + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/translation-install.php'; + } + + $languages = \get_available_languages(); + $translations = \wp_get_available_translations(); + + $options = [ + [ + 'value' => '', + 'label' => 'English (United States)', + ], + ]; + + foreach ( $languages as $locale ) { + $label = isset( $translations[ $locale ] ) + ? $translations[ $locale ]['native_name'] + : $locale; + + $options[] = [ + 'value' => $locale, + 'label' => $label, + ]; + } + + // Include installable languages if user can install them. + if ( \current_user_can( 'install_languages' ) && \wp_can_install_language_pack() ) { + foreach ( $translations as $translation ) { + if ( \in_array( $translation['language'], $languages, true ) ) { + continue; + } + + $options[] = [ + 'value' => $translation['language'], + 'label' => $translation['native_name'], + ]; + } + } + + return $options; + } +} diff --git a/classes/rest/class-page-settings.php b/classes/rest/class-page-settings.php new file mode 100644 index 0000000000..81b350ad5f --- /dev/null +++ b/classes/rest/class-page-settings.php @@ -0,0 +1,54 @@ + 'GET', + 'callback' => [ $this, 'get_page_settings' ], + 'permission_callback' => [ $this, 'permission_callback' ], + ] + ); + } + + /** + * Get page settings data. + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response The REST response object. + */ + public function get_page_settings( $request ) { + $settings = \progress_planner()->get_admin__page_settings()->get_settings(); + + return new \WP_REST_Response( $settings, 200 ); + } + + /** + * Permission callback for page settings endpoint. + * + * @return bool + */ + public function permission_callback() { + return \current_user_can( 'manage_options' ); + } +} diff --git a/classes/rest/class-plugin-installer.php b/classes/rest/class-plugin-installer.php new file mode 100644 index 0000000000..036721d03c --- /dev/null +++ b/classes/rest/class-plugin-installer.php @@ -0,0 +1,275 @@ + 'POST', + 'callback' => [ $this, 'install_plugin' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'plugin_slug' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ], + ], + ], + ] + ); + + \register_rest_route( + 'progress-planner/v1', + '/plugins/activate', + [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'activate_plugin' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'plugin_slug' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'install_plugins' ); + } + + /** + * Install a plugin. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function install_plugin( $request ) { + $slug = $request->get_param( 'plugin_slug' ); + + // If the plugin slug is empty, return an error. + if ( empty( $slug ) ) { + return new \WP_Error( + 'empty_plugin_slug', + \esc_html__( 'An Error Occured', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + // If the plugin is already installed, return a success message. + if ( $this->is_plugin_installed( $slug ) ) { + return new \WP_REST_Response( + [ + 'code' => 'plugin_already_installed', + 'message' => \esc_html__( 'Plugin already installed', 'progress-planner' ), + ], + 200 + ); + } + + // Install the plugin. + $installed = $this->do_install_plugin( $slug ); + + // If the plugin is installed, return a success message. + if ( $installed && ! \is_wp_error( $installed ) ) { + return new \WP_REST_Response( + [ + 'code' => 'plugin_installed', + 'message' => \esc_html__( 'Plugin installed', 'progress-planner' ), + ], + 200 + ); + } + + // If the plugin installation returned a WP_Error, use that error. + if ( \is_wp_error( $installed ) ) { + return new \WP_Error( + $installed->get_error_code(), + $installed->get_error_message(), + [ 'status' => 500 ] + ); + } + + // If the plugin is not installed, return an error message. + return new \WP_Error( + 'install_failed', + \esc_html__( 'An Error Occured', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + /** + * Activate a plugin. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function activate_plugin( $request ) { + $plugin_slug = $request->get_param( 'plugin_slug' ); + + // If the plugin slug is empty, return an error. + if ( empty( $plugin_slug ) ) { + return new \WP_Error( + 'empty_plugin_slug', + \esc_html__( 'An Error Occured', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + // Get the plugin path. + $plugin_path = $this->get_plugin_path( $plugin_slug ); + + // If the plugin path is empty, return an error. + if ( empty( $plugin_path ) ) { + return new \WP_Error( + 'plugin_not_found', + \esc_html__( 'An Error Occured', 'progress-planner' ), + [ 'status' => 404 ] + ); + } + + // Activate the plugin. + $activated = \activate_plugin( $plugin_path ); + + // If the plugin is not activated, return an error message. + if ( \is_wp_error( $activated ) ) { + return new \WP_Error( + 'activate_failed', + \esc_html__( 'An Error Occured', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + // If the plugin is activated, return a success message. + return new \WP_REST_Response( + [ + 'code' => 'plugin_activated', + 'message' => \esc_html__( 'Plugin activated', 'progress-planner' ), + ], + 200 + ); + } + + /** + * Install a plugin. + * + * @param string $plugin The plugin to install. + * + * @return bool|\WP_Error + */ + private function do_install_plugin( $plugin ) { + // Include the necessary files. + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; // @phpstan-ignore-line + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; // @phpstan-ignore-line + require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php'; // @phpstan-ignore-line + require_once ABSPATH . 'wp-admin/includes/class-plugin-installer-skin.php'; // @phpstan-ignore-line + + // Get the plugin information. + $api = \plugins_api( + 'plugin_information', + [ + 'slug' => $plugin, + 'fields' => [ + 'sections' => false, + ], + ] + ); + + // If the plugin information is not found, return an error. + if ( \is_wp_error( $api ) ) { + return $api; + } + + $api = (object) $api; + // If the plugin download link is not found, return an error. + if ( ! isset( $api->download_link ) ) { + return new \WP_Error( 'no_download_link', \__( 'No download link found', 'progress-planner' ) ); + } + + // If the plugin name is not found, return an error. + if ( ! isset( $api->name ) ) { + return new \WP_Error( 'no_name', \__( 'No name found', 'progress-planner' ) ); + } + + // If the plugin version is not found, return an error. + if ( ! isset( $api->version ) ) { + return new \WP_Error( 'no_version', \__( 'No version found', 'progress-planner' ) ); + } + + // Create a new plugin upgrader. + $upgrader = new \Plugin_Upgrader( + new \Plugin_Installer_Skin( + [ + 'type' => 'web', + /* translators: %s: Plugin name and version. */ + 'title' => \sprintf( \__( 'Installing Plugin: %s', 'progress-planner' ), $api->name . ' ' . $api->version ), + 'url' => 'update.php?action=install-plugin&plugin=' . \rawurlencode( $plugin ), + 'nonce' => 'install-plugin_' . $plugin, + 'plugin' => $plugin, + 'api' => $api, + ] + ) + ); + + // Install the plugin. + return $upgrader->install( $api->download_link ); + } + + /** + * Checks if plugin is installed. + * + * @param string $plugin_slug The slug of the plugin we want to check. + * + * @return bool + */ + private function is_plugin_installed( $plugin_slug ) { + return \Progress_Planner\Utils\Plugin_Utils::is_plugin_installed( $plugin_slug ); + } + + /** + * Get the path of the plugin. + * + * @param string $plugin_slug The slug of the plugin we want to get the path for. + * + * @return string + */ + private function get_plugin_path( $plugin_slug ) { + return \Progress_Planner\Utils\Plugin_Utils::get_plugin_path( $plugin_slug ); + } +} diff --git a/classes/rest/class-popover-actions.php b/classes/rest/class-popover-actions.php new file mode 100644 index 0000000000..fbdb645f63 --- /dev/null +++ b/classes/rest/class-popover-actions.php @@ -0,0 +1,189 @@ + 'POST', + 'callback' => [ $this, 'submit_action' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'setting' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ], + 'value' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + // Allow empty strings as some settings may be empty. + 'validate_callback' => function ( $param ) { + return null !== $param; + }, + ], + 'setting_path' => [ + 'required' => false, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + 'description' => 'JSON string representing the path to nested setting (e.g., \'["key1","key2"]\')', + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Handle popover form submission. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function submit_action( $request ) { + $setting = $request->get_param( 'setting' ); + $value = $request->get_param( 'value' ); + $setting_path = $request->get_param( 'setting_path' ); + + // Get allowed options from Tasks_Interactive class. + // We need to instantiate a temporary instance to access the protected method. + // Since it's abstract, we'll use a reflection-based approach or create a helper. + $allowed_options = $this->get_allowed_interactive_options(); + + if ( ! \in_array( $setting, $allowed_options, true ) ) { + return new \WP_Error( + 'invalid_setting', + \esc_html__( 'Invalid setting. This option cannot be updated through interactive tasks.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + + // Handle nested setting paths. + if ( ! empty( $setting_path ) ) { + $setting_path_array = \json_decode( $setting_path, true ); + // Check if JSON decode was successful and result is a non-empty array. + if ( \json_last_error() !== \JSON_ERROR_NONE || ! \is_array( $setting_path_array ) || empty( $setting_path_array ) ) { + return new \WP_Error( + 'invalid_setting_path', + \esc_html__( 'Invalid setting path format. Expected a JSON array.', 'progress-planner' ), + [ 'status' => 400 ] + ); + } + $setting_value = \get_option( $setting, [] ); + // Ensure setting_value is an array for nested path updates. + if ( ! \is_array( $setting_value ) ) { + $setting_value = []; + } + \_wp_array_set( $setting_value, $setting_path_array, $value ); + $updated = \update_option( $setting, $setting_value ); + if ( ! $updated ) { + return new \WP_Error( + 'update_failed', + \esc_html__( 'Failed to update setting.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + return new \WP_REST_Response( + [ + 'success' => true, + 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ), + ], + 200 + ); + } + + // Handle simple setting update. + $updated = \update_option( $setting, $value ); + if ( ! $updated ) { + return new \WP_Error( + 'update_failed', + \esc_html__( 'Failed to update setting.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ), + ], + 200 + ); + } + + /** + * Get the list of allowed options that can be updated via interactive tasks. + * + * This mirrors the method from Tasks_Interactive class. + * + * @return array List of allowed option names. + */ + private function get_allowed_interactive_options() { + $allowed_options = [ + // Core WordPress settings that are safe to update via interactive tasks. + 'blogdescription', // Site tagline. + 'blog_public', // Search engine visibility (allow indexing). + 'default_comment_status', // Comment settings. + 'default_ping_status', // Pingback settings. + 'timezone_string', // Site timezone. + 'WPLANG', // Site language/locale (deprecated since WP 4.0, but still used by class-select-locale.php). + 'date_format', // Date format. + 'time_format', // Time format. + 'default_pingback_flag', // Pingback flag. + 'comment_registration', // Comment registration. + 'close_comments_for_old_posts', // Close comments for old posts. + 'thread_comments', // Threaded comments. + 'comments_per_page', // Comments per page. + 'comment_order', // Comment order. + 'page_comments', // Paginate comments. + ]; + + /** + * Filter the list of allowed options for interactive tasks. + * + * WARNING: Be very careful when extending this list. Adding sensitive + * options like 'admin_email', 'users_can_register', or plugin-specific + * options that control access or permissions could create security vulnerabilities. + * + * @param array $allowed_options List of allowed option names. + * + * @return array Modified list of allowed option names. + */ + return \apply_filters( 'progress_planner_interactive_task_allowed_options', $allowed_options ); + } +} diff --git a/classes/rest/class-stats.php b/classes/rest/class-stats.php index 5ff4217bb9..d93c074804 100644 --- a/classes/rest/class-stats.php +++ b/classes/rest/class-stats.php @@ -12,8 +12,6 @@ namespace Progress_Planner\Rest; -use Progress_Planner\Admin\Widgets\Activity_Scores; - /** * Rest_API_Stats class. */ diff --git a/classes/rest/class-subscribe.php b/classes/rest/class-subscribe.php new file mode 100644 index 0000000000..d8897b9427 --- /dev/null +++ b/classes/rest/class-subscribe.php @@ -0,0 +1,142 @@ + 'POST', + 'callback' => [ $this, 'subscribe' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'name' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ], + 'email' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'validate_callback' => function ( $param ) { + return \is_email( $param ); + }, + ], + 'site' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'esc_url_raw', + 'validate_callback' => function ( $param ) { + return ! empty( $param ); + }, + ], + 'timezone_offset' => [ + 'required' => false, + 'type' => 'number', + 'default' => 0, + ], + 'with_email' => [ + 'required' => false, + 'type' => 'string', + 'default' => 'yes', + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Handle subscription. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function subscribe( $request ) { + $name = $request->get_param( 'name' ); + $email = $request->get_param( 'email' ); + $site = $request->get_param( 'site' ); + $timezone_offset = $request->get_param( 'timezone_offset' ); + $with_email = $request->get_param( 'with_email' ); + + // Build data object for onboarding API. + $data = [ + 'name' => $name, + 'email' => $email, + 'site' => $site, + 'timezone_offset' => $timezone_offset, + 'with-email' => $with_email, + ]; + + // Get nonce from external API. + $onboard_utils = \progress_planner()->get_utils__onboard(); + $nonce = $onboard_utils->get_remote_nonce(); + + if ( empty( $nonce ) ) { + return new \WP_Error( + 'nonce_failed', + \esc_html__( 'Failed to get nonce from remote server.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + // Add nonce to data. + $data['nonce'] = $nonce; + + // Make request to external API using the Onboard utility. + $license_key = $onboard_utils->make_remote_onboarding_request( $data ); + + if ( empty( $license_key ) ) { + return new \WP_Error( + 'api_request_failed', + \esc_html__( 'Failed to submit subscription.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + // Save the license key to the database. + \update_option( 'progress_planner_license_key', $license_key, false ); + + return new \WP_REST_Response( + [ + 'success' => true, + 'message' => \esc_html__( 'Subscription successful.', 'progress-planner' ), + 'license_key' => $license_key, + ], + 200 + ); + } +} diff --git a/classes/rest/class-task-evaluation.php b/classes/rest/class-task-evaluation.php new file mode 100644 index 0000000000..76b7274d24 --- /dev/null +++ b/classes/rest/class-task-evaluation.php @@ -0,0 +1,636 @@ + 'POST', + 'callback' => [ $this, 'create_task' ], + 'permission_callback' => [ $this, 'permission_callback' ], + 'args' => [ + 'task_details' => [ + 'required' => true, + 'type' => 'object', + 'validate_callback' => [ $this, 'validate_task_details' ], + 'sanitize_callback' => [ $this, 'sanitize_task_details' ], + ], + ], + ], + ] + ); + + // Batch task creation endpoint. + \register_rest_route( + 'progress-planner/v1', + '/tasks/evaluate-batch', + [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'create_tasks_batch' ], + 'permission_callback' => [ $this, 'permission_callback' ], + 'args' => [ + 'tasks' => [ + 'required' => true, + 'type' => 'array', + ], + ], + ], + ] + ); + } + + /** + * Permission callback for task evaluation endpoint. + * + * @return bool + */ + public function permission_callback() { + return \current_user_can( 'edit_others_posts' ); + } + + /** + * Validate task details. + * + * @param mixed $value The task details array. + * @param \WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return bool + */ + public function validate_task_details( $value, $request, $param ) { + if ( ! \is_array( $value ) ) { + return false; + } + + // Required fields. + $required = [ 'task_id', 'provider_id', 'post_title' ]; + foreach ( $required as $field ) { + if ( ! isset( $value[ $field ] ) || empty( $value[ $field ] ) ) { + return false; + } + } + + return true; + } + + /** + * Sanitize task details. + * + * @param array $value The task details array. + * @param \WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return array + */ + public function sanitize_task_details( $value, $request, $param ) { + $sanitized = []; + + // Sanitize string fields. + $string_fields = [ + 'task_id', + 'provider_id', + 'post_title', + 'description', + 'url', + 'url_target', + 'external_link_url', + ]; + foreach ( $string_fields as $field ) { + if ( isset( $value[ $field ] ) ) { + $sanitized[ $field ] = \sanitize_text_field( $value[ $field ] ); + } + } + + // Sanitize integer fields. + $int_fields = [ 'parent', 'priority', 'points', 'order' ]; + foreach ( $int_fields as $field ) { + if ( isset( $value[ $field ] ) ) { + $sanitized[ $field ] = (int) $value[ $field ]; + } + } + + // Sanitize boolean fields. + $bool_fields = [ 'dismissable' ]; + foreach ( $bool_fields as $field ) { + if ( isset( $value[ $field ] ) ) { + $sanitized[ $field ] = (bool) $value[ $field ]; + } + } + + // Preserve array fields (like link_setting). + $array_fields = [ 'link_setting' ]; + foreach ( $array_fields as $field ) { + if ( isset( $value[ $field ] ) && \is_array( $value[ $field ] ) ) { + $sanitized[ $field ] = $value[ $field ]; + } + } + + return $sanitized; + } + + /** + * Create a task post. + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response|\WP_Error The REST response object or error. + */ + public function create_task( $request ) { + $task_details = $request->get_param( 'task_details' ); + $result = $this->create_single_task( $task_details ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + $status_code = isset( $result['post_id'] ) && $result['message'] === \esc_html__( 'Task created successfully.', 'progress-planner' ) ? 201 : 200; + return new \WP_REST_Response( $result, $status_code ); + } + + /** + * Create multiple tasks in a batch with optimized database operations. + * + * Optimizations over sequential processing: + * - Single lock for entire batch (vs per-task locks) + * - Bulk existence check with one query (vs per-task queries) + * - Pre-fetch all provider terms at once (vs per-task term lookups) + * - Direct response building (no internal REST calls) + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response The REST response object. + */ + public function create_tasks_batch( $request ) { + $tasks = $request->get_param( 'tasks' ); + if ( empty( $tasks ) ) { + return new \WP_REST_Response( + [ + 'success' => true, + 'tasks' => [], + ], + 200 + ); + } + + // Sanitize all tasks first. + $sanitized_tasks = []; + foreach ( $tasks as $task_details ) { + $sanitized_tasks[] = $this->sanitize_task_details( $task_details, $request, 'tasks' ); + } + + // Acquire a single lock for the entire batch. + $lock_key = 'prpl_batch_lock_' . \md5( \wp_json_encode( \array_column( $sanitized_tasks, 'task_id' ) ) ); + $lock_value = \time(); + + if ( ! \add_option( $lock_key, $lock_value, '', false ) ) { + $current = \get_option( $lock_key ); + if ( ! $current || $current >= \time() - 30 ) { + // Lock is held by another active process. + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => \esc_html__( 'Batch creation in progress by another process.', 'progress-planner' ), + ], + 409 + ); + } + // Stale lock, take it over. + \update_option( $lock_key, $lock_value ); + } + + try { + $results = $this->process_batch_tasks( $sanitized_tasks ); + } finally { + \delete_option( $lock_key ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'tasks' => $results, + ], + 201 + ); + } + + /** + * Process batch tasks with optimized database operations. + * + * @param array $sanitized_tasks Array of sanitized task details. + * @return array Array of results for each task. + */ + private function process_batch_tasks( $sanitized_tasks ) { + $results = []; + + // Step 1: Bulk check for existing tasks (single query). + $task_ids = \array_column( $sanitized_tasks, 'task_id' ); + $existing_map = $this->bulk_check_existing_tasks( $task_ids ); + + // Step 2: Pre-fetch/create all needed provider terms. + $provider_ids = \array_unique( \array_column( $sanitized_tasks, 'provider_id' ) ); + $term_map = $this->ensure_provider_terms( $provider_ids ); + + // Step 3: Process each task - create only those that don't exist. + foreach ( $sanitized_tasks as $task_details ) { + $task_id = (string) $task_details['task_id']; + + // Check if task exists from our bulk query. + if ( isset( $existing_map[ $task_id ] ) ) { + $existing = $existing_map[ $task_id ]; + $results[] = [ + 'success' => true, + 'post_id' => $existing->ID, + 'task' => $this->build_task_response_from_post( $existing ), + 'message' => \esc_html__( 'Task already exists.', 'progress-planner' ), + ]; + continue; + } + + // Prepare task data. + $task_data = [ + 'task_id' => $task_details['task_id'], + 'provider_id' => $task_details['provider_id'], + 'post_title' => $task_details['post_title'], + 'description' => $task_details['description'] ?? '', + 'priority' => $task_details['priority'] ?? 50, + 'points' => $task_details['points'] ?? 1, + 'parent' => $task_details['parent'] ?? 0, + 'order' => $task_details['order'] ?? ( $task_details['priority'] ?? 50 ), + 'url' => $task_details['url'] ?? '', + 'url_target' => $task_details['url_target'] ?? '_self', + 'external_link_url' => $task_details['external_link_url'] ?? '', + 'dismissable' => $task_details['dismissable'] ?? false, + 'post_status' => 'publish', + ]; + + if ( isset( $task_details['link_setting'] ) && \is_array( $task_details['link_setting'] ) ) { + $task_data['link_setting'] = $task_details['link_setting']; + } + + // Create the task post directly (bypass Suggested_Tasks_DB::add() to avoid per-task locking). + $post_id = $this->create_task_post( $task_data, $term_map ); + + if ( ! $post_id ) { + $results[] = [ + 'success' => false, + 'message' => \esc_html__( 'Failed to create task.', 'progress-planner' ), + ]; + continue; + } + + $results[] = [ + 'success' => true, + 'post_id' => $post_id, + 'task' => $this->build_task_response( $post_id, $task_data ), + 'message' => \esc_html__( 'Task created successfully.', 'progress-planner' ), + ]; + } + + return $results; + } + + /** + * Bulk check for existing tasks with a single database query. + * + * @param string[] $task_ids Array of task IDs to check. + * @return array Map of task_id => WP_Post for existing tasks. + */ + private function bulk_check_existing_tasks( array $task_ids ) { + if ( empty( $task_ids ) ) { + return []; + } + + // Convert task IDs to post slugs. + $slugs = []; + $trashed_slugs = []; + foreach ( $task_ids as $task_id ) { + $slug = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task_id ); + $slugs[] = $slug; + $trashed_slugs[] = $slug . '__trashed'; + } + + // Single query for all tasks (including trashed). + $all_slugs = \array_merge( $slugs, $trashed_slugs ); + $posts = \get_posts( + [ + 'post_type' => 'prpl_recommendations', + 'post_status' => [ 'publish', 'trash', 'draft', 'future', 'pending' ], + 'post_name__in' => $all_slugs, + 'posts_per_page' => \count( $all_slugs ), + 'no_found_rows' => true, + ] + ); + + // Build map of task_id => post. + $map = []; + foreach ( $posts as $post ) { + // Find the original task_id this post belongs to. + $slug = $post->post_name; + // Remove __trashed suffix if present. + $clean_slug = \preg_replace( '/__trashed$/', '', $slug ); + + // Find matching task_id. + foreach ( $task_ids as $task_id ) { + $expected_slug = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task_id ); + if ( $clean_slug === $expected_slug ) { + $map[ $task_id ] = $post; + break; + } + } + } + + return $map; + } + + /** + * Ensure all provider terms exist, creating any that don't. + * + * @param string[] $provider_ids Array of provider IDs. + * @return array Map of provider_id => term_id. + */ + private function ensure_provider_terms( array $provider_ids ) { + if ( empty( $provider_ids ) ) { + return []; + } + + $taxonomy = 'prpl_recommendations_provider'; + $term_map = []; + + // Get all existing terms in one query. + $existing_terms = \get_terms( + [ + 'taxonomy' => $taxonomy, + 'name' => $provider_ids, + 'hide_empty' => false, + ] + ); + + if ( ! \is_wp_error( $existing_terms ) ) { + foreach ( $existing_terms as $term ) { + $term_map[ $term->name ] = $term->term_id; + } + } + + // Create any missing terms. + foreach ( $provider_ids as $provider_id ) { + if ( ! isset( $term_map[ $provider_id ] ) ) { + $result = \wp_insert_term( $provider_id, $taxonomy ); + if ( ! \is_wp_error( $result ) ) { + $term_map[ $provider_id ] = $result['term_id']; + } + } + } + + return $term_map; + } + + /** + * Create a task post directly (optimized for batch processing). + * + * @param array $task_data The task data. + * @param array $term_map Map of provider_id => term_id. + * @return int|false The post ID or false on failure. + */ + private function create_task_post( array $task_data, array $term_map ) { + $args = [ + 'post_type' => 'prpl_recommendations', + 'post_title' => $task_data['post_title'], + 'post_content' => $task_data['description'] ?? '', + 'post_status' => 'publish', + 'menu_order' => $task_data['order'] ?? 0, + 'post_name' => \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( (string) $task_data['task_id'] ), + ]; + + $post_id = \wp_insert_post( $args ); + if ( ! $post_id ) { + return false; + } + + // Set provider term using pre-fetched term_id. + $provider_id = (string) $task_data['provider_id']; + if ( isset( $term_map[ $provider_id ] ) ) { + \wp_set_post_terms( $post_id, [ $term_map[ $provider_id ] ], 'prpl_recommendations_provider' ); + } + + // Set meta fields. + $meta_fields = [ + 'task_id', + 'priority', + 'points', + 'url', + 'url_target', + 'external_link_url', + 'dismissable', + 'link_setting', + ]; + + foreach ( $meta_fields as $field ) { + if ( isset( $task_data[ $field ] ) ) { + \update_post_meta( $post_id, "prpl_$field", $task_data[ $field ] ); + } + } + + return $post_id; + } + + /** + * Build task response from an existing post object. + * + * @param \WP_Post $post The post object. + * @return array The task response data. + */ + private function build_task_response_from_post( $post ) { + // Fetch all meta values. + $priority = \get_post_meta( $post->ID, 'prpl_priority', true ); + $points = \get_post_meta( $post->ID, 'prpl_points', true ); + $url = \get_post_meta( $post->ID, 'prpl_url', true ); + $url_target = \get_post_meta( $post->ID, 'prpl_url_target', true ); + $external_link = \get_post_meta( $post->ID, 'prpl_external_link_url', true ); + $link_setting = \get_post_meta( $post->ID, 'prpl_link_setting', true ); + + return [ + 'id' => $post->ID, + 'date' => $post->post_date, + 'date_gmt' => $post->post_date_gmt, + 'modified' => $post->post_modified, + 'modified_gmt' => $post->post_modified_gmt, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'type' => $post->post_type, + 'link' => \get_permalink( $post->ID ), + 'title' => [ + 'rendered' => $post->post_title, + ], + 'content' => [ + 'rendered' => $post->post_content, + ], + 'menu_order' => $post->menu_order, + 'prpl_priority' => $priority ? $priority : 50, + 'prpl_points' => $points ? $points : 1, + 'prpl_url' => $url ? $url : '', + 'prpl_url_target' => $url_target ? $url_target : '_self', + 'prpl_external_link' => $external_link ? $external_link : '', + 'prpl_dismissable' => (bool) \get_post_meta( $post->ID, 'prpl_dismissable', true ), + 'prpl_link_setting' => $link_setting ? $link_setting : null, + 'prpl_provider' => $this->get_post_provider( $post->ID ), + ]; + } + + /** + * Get the provider ID for a post. + * + * @param int $post_id The post ID. + * @return string|null The provider ID or null. + */ + private function get_post_provider( $post_id ) { + $terms = \wp_get_post_terms( $post_id, 'prpl_recommendations_provider' ); + if ( ! empty( $terms ) && ! \is_wp_error( $terms ) ) { + return $terms[0]->name; + } + return null; + } + + /** + * Create a single task (internal helper). + * + * @param array $task_details The task details. + * @return array|\WP_Error Result array with task data or WP_Error. + */ + private function create_single_task( $task_details ) { + // Check if task already exists. + $existing_task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_details['task_id'] ); + if ( $existing_task ) { + // Task already exists, return full task data. + $task_data = $this->get_full_task_data( $existing_task->ID ); + return [ + 'success' => true, + 'post_id' => $existing_task->ID, + 'task' => $task_data, + 'message' => \esc_html__( 'Task already exists.', 'progress-planner' ), + ]; + } + + // Prepare data for task creation. + $task_data = [ + 'task_id' => $task_details['task_id'], + 'provider_id' => $task_details['provider_id'], + 'post_title' => $task_details['post_title'], + 'description' => $task_details['description'] ?? '', + 'priority' => $task_details['priority'] ?? 50, + 'points' => $task_details['points'] ?? 1, + 'parent' => $task_details['parent'] ?? 0, + 'order' => $task_details['order'] ?? ( $task_details['priority'] ?? 50 ), + 'url' => $task_details['url'] ?? '', + 'url_target' => $task_details['url_target'] ?? '_self', + 'external_link_url' => $task_details['external_link_url'] ?? '', + 'dismissable' => $task_details['dismissable'] ?? false, + 'post_status' => 'publish', + ]; + + // Add link_setting if provided. + if ( isset( $task_details['link_setting'] ) && \is_array( $task_details['link_setting'] ) ) { + $task_data['link_setting'] = $task_details['link_setting']; + } + + // Create the task post. + $post_id = \progress_planner()->get_suggested_tasks_db()->add( $task_data ); + + if ( ! $post_id ) { + return new \WP_Error( + 'rest_task_creation_failed', + \esc_html__( 'Failed to create task.', 'progress-planner' ), + [ 'status' => 500 ] + ); + } + + // Build response directly from task data (avoids expensive internal REST call). + $full_task_data = $this->build_task_response( $post_id, $task_data ); + + return [ + 'success' => true, + 'post_id' => $post_id, + 'task' => $full_task_data, + 'message' => \esc_html__( 'Task created successfully.', 'progress-planner' ), + ]; + } + + /** + * Get full task data via REST API internal request. + * Used for existing tasks where we don't have the original data. + * + * @param int $post_id The post ID. + * @return array|null The task data or null on error. + */ + private function get_full_task_data( $post_id ) { + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations/' . $post_id ); + $request->set_param( '_embed', true ); + $response = \rest_do_request( $request ); + + if ( $response->is_error() ) { + return null; + } + + return $response->get_data(); + } + + /** + * Build task response directly from task data (avoids internal REST call). + * + * @param int $post_id The post ID. + * @param array $task_data The task data used to create the post. + * @return array The task response data. + */ + private function build_task_response( $post_id, $task_data ) { + $post = \get_post( $post_id ); + if ( ! $post ) { + return null; + } + + // Build response matching REST API format. + return [ + 'id' => $post_id, + 'date' => $post->post_date, + 'date_gmt' => $post->post_date_gmt, + 'modified' => $post->post_modified, + 'modified_gmt' => $post->post_modified_gmt, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'type' => $post->post_type, + 'link' => \get_permalink( $post_id ), + 'title' => [ + 'rendered' => $task_data['post_title'], + ], + 'content' => [ + 'rendered' => $task_data['description'] ?? '', + ], + 'menu_order' => $post->menu_order, + 'prpl_priority' => $task_data['priority'] ?? 50, + 'prpl_points' => $task_data['points'] ?? 1, + 'prpl_url' => $task_data['url'] ?? '', + 'prpl_url_target' => $task_data['url_target'] ?? '_self', + 'prpl_external_link' => $task_data['external_link_url'] ?? '', + 'prpl_dismissable' => $task_data['dismissable'] ?? false, + 'prpl_link_setting' => $task_data['link_setting'] ?? null, + 'prpl_provider' => $task_data['provider_id'], + ]; + } +} diff --git a/classes/rest/class-timezone-options.php b/classes/rest/class-timezone-options.php new file mode 100644 index 0000000000..15e6b9fc44 --- /dev/null +++ b/classes/rest/class-timezone-options.php @@ -0,0 +1,123 @@ + 'GET', + 'callback' => [ $this, 'get_timezone_options' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get timezone options. + * + * @return \WP_REST_Response + */ + public function get_timezone_options() { + $options = $this->build_timezone_options(); + + return new \WP_REST_Response( $options, 200 ); + } + + /** + * Build timezone options array. + * + * Uses WordPress's wp_timezone_choice() function and extracts options from the HTML. + * This ensures we stay in sync with WordPress core behavior. + * + * @return array Array of timezone option objects with 'value' and 'label' properties. + */ + private function build_timezone_options() { + // Get HTML output from WordPress core function. + $html = \wp_timezone_choice( '', \get_user_locale() ); + + // Parse HTML to extract option elements. + $options = $this->parse_timezone_html( $html ); + + return $options; + } + + /** + * Parse HTML from wp_timezone_choice() to extract timezone options. + * + * Uses WP_HTML_Tag_Processor to find option tags and extract both value attributes + * and text content using next_token() and get_modifiable_text(). + * + * @param string $html The HTML output from wp_timezone_choice(). + * @return array Array of timezone option objects with 'value' and 'label' properties. + */ + private function parse_timezone_html( $html ) { + $options = []; + + // Use WP_HTML_Tag_Processor to find option tags and extract data. + $processor = new \WP_HTML_Tag_Processor( $html ); + while ( $processor->next_tag( 'option' ) ) { + $value = $processor->get_attribute( 'value' ); + + // Extract text content by moving through tokens after the opening tag. + // Collect all text nodes until we hit the closing tag. + $label = ''; + while ( $processor->next_token() ) { + $token_name = $processor->get_token_name(); + + // If we hit the closing option tag, we're done collecting text. + if ( 'OPTION' === $token_name && $processor->is_tag_closer() ) { + break; + } + + // Collect text from text nodes (decoded automatically by get_modifiable_text). + if ( '#text' === $token_name ) { + $label .= $processor->get_modifiable_text(); + } + } + + $label = \trim( $label ); + + // Skip empty options (like the "Select a city" placeholder). + if ( ( null === $value || '' === $value ) && '' === $label ) { + continue; + } + + $options[] = [ + 'value' => $value ?? '', + 'label' => $label, + ]; + } + + return $options; + } +} diff --git a/classes/rest/class-updates.php b/classes/rest/class-updates.php new file mode 100644 index 0000000000..a9ecba2318 --- /dev/null +++ b/classes/rest/class-updates.php @@ -0,0 +1,137 @@ + 'GET', + 'callback' => [ $this, 'get_updates' ], + 'permission_callback' => [ $this, 'permission_callback' ], + ] + ); + } + + /** + * Get update data. + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response|\WP_Error The REST response object or error. + */ + public function get_updates( $request ) { + // Ensure update functions are loaded. + if ( ! \function_exists( 'get_core_updates' ) ) { + // @phpstan-ignore-next-line requireOnce.fileNotFound + require_once ABSPATH . 'wp-admin/includes/update.php'; + } + + // Get update data. + $update_data = \wp_get_update_data(); + + // Format response. + $response = [ + 'core' => [], + 'plugins' => [], + 'themes' => [], + 'counts' => [ + 'total' => $update_data['counts']['total'] ?? 0, + 'core' => $update_data['counts']['core'] ?? 0, + 'plugins' => $update_data['counts']['plugins'] ?? 0, + 'themes' => $update_data['counts']['themes'] ?? 0, + 'translations' => $update_data['counts']['translations'] ?? 0, + ], + ]; + + // Get core updates. + $core_updates = \get_core_updates(); + if ( \is_array( $core_updates ) && ! empty( $core_updates ) ) { + $response['core'] = \array_map( + function ( $update ) { + return [ + 'version' => $update->version ?? '', + 'current' => $update->current ?? '', + 'download' => $update->download ?? '', + 'locale' => $update->locale ?? '', + 'packages' => $update->packages ?? [], + 'response' => $update->response ?? '', + 'php_version' => $update->php_version ?? '', + ]; + }, + \array_filter( $core_updates, 'is_object' ) + ); + } + + // Get plugin updates. + $plugin_updates = \get_plugin_updates(); + if ( $plugin_updates ) { + $response['plugins'] = \array_map( + function ( $file, $data ) { + $update = isset( $data->update ) && \is_object( $data->update ) ? $data->update : null; + return [ + 'file' => $file, + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'name' => $data->Name ?? '', + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'version' => $data->Version ?? '', + 'new_version' => $update->new_version ?? '', + 'url' => $update->url ?? '', + 'package' => $update->package ?? '', + ]; + }, + \array_keys( $plugin_updates ), + $plugin_updates + ); + } + + // Get theme updates. + $theme_updates = \get_theme_updates(); + if ( $theme_updates ) { + $response['themes'] = \array_map( + function ( $stylesheet, $data ) { + return [ + 'stylesheet' => $stylesheet, + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'name' => $data->Name ?? '', + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'version' => $data->Version ?? '', + 'new_version' => $data->update['new_version'] ?? '', + 'url' => $data->update['url'] ?? '', + 'package' => $data->update['package'] ?? '', + ]; + }, + \array_keys( $theme_updates ), + $theme_updates + ); + } + + return new \WP_REST_Response( $response, 200 ); + } + + /** + * Permission callback for updates endpoint. + * + * @return bool + */ + public function permission_callback() { + return \current_user_can( 'update_core' ); + } +} diff --git a/classes/rest/class-upgrade-tasks-config.php b/classes/rest/class-upgrade-tasks-config.php new file mode 100644 index 0000000000..2120e4b117 --- /dev/null +++ b/classes/rest/class-upgrade-tasks-config.php @@ -0,0 +1,102 @@ + 'GET', + 'callback' => [ $this, 'get_config' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get upgrade tasks configuration. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_config( $request ) { + $plugin_upgrade_tasks = \progress_planner()->get_plugin_upgrade_tasks(); + $task_providers = $plugin_upgrade_tasks->get_newly_added_task_providers(); + + // Build task providers data for React. + $task_providers_data = []; + foreach ( $task_providers as $task_provider ) { + $task_data = [ + 'task_id' => $task_provider->get_task_id(), + 'provider_id' => $task_provider->get_provider_id(), + ]; + + // Get task details. + $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ); + + // If task doesn't exist, add it. + if ( ! $task ) { + $task_post_id = \progress_planner()->get_suggested_tasks_db()->add( $task_provider->get_task_details( $task_data ) ); + if ( $task_post_id ) { + $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_post_id ); + } + } + + if ( $task ) { + $task_completed = $task_provider->evaluate_task( $task_data['task_id'] ); + + $task_providers_data[] = [ + 'task_id' => $task_data['task_id'], + 'title' => $task->post_title, + 'points' => $task->points ?? 0, + 'completed' => $task_completed, + ]; + } + } + + // Generate monthly badge ID. + $now = new \DateTime(); + $badge_id = 'monthly-' . $now->format( 'Y' ) . '-m' . $now->format( 'n' ); + + return new \WP_REST_Response( + [ + 'taskProviders' => $task_providers_data, + 'brandingId' => (int) \progress_planner()->get_ui__branding()->get_branding_id(), + 'remoteServerUrl' => \progress_planner()->get_remote_server_root_url(), + 'placeholderSvg' => \progress_planner()->get_placeholder_svg(), + 'badgeId' => $badge_id, + ], + 200 + ); + } +} diff --git a/classes/rest/class-wizard-config.php b/classes/rest/class-wizard-config.php new file mode 100644 index 0000000000..11d3d71be8 --- /dev/null +++ b/classes/rest/class-wizard-config.php @@ -0,0 +1,180 @@ + 'GET', + 'callback' => [ $this, 'get_wizard_config' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get wizard configuration. + * + * @param \WP_REST_Request $request The REST request object. + * @return \WP_REST_Response|\WP_Error + */ + public function get_wizard_config( $request ) { + $wizard = \progress_planner()->get_onboard_wizard(); + + // Get saved progress from user meta. + $saved_progress = $wizard->get_saved_progress(); + + // Force re-calculation of steps. + // Tasks may have been created by React just before this request, + // so we need fresh data instead of cached steps from the init hook. + $wizard->define_steps_and_order(); + + // Get steps using public method. + $steps = $wizard->get_steps(); + + // Format steps for React. + $steps_formatted = []; + foreach ( $steps as $index => $step ) { + $steps_formatted[] = [ + 'id' => $step['template_id'], + 'title' => \html_entity_decode( $step['title'], ENT_QUOTES, 'UTF-8' ), + 'data' => isset( $step['template_data'] ) ? $step['template_data'] : [], + ]; + } + + // Get pages for settings step. + $pages = \get_pages( + [ + 'sort_column' => 'post_title', + 'sort_order' => 'ASC', + ] + ); + $pages_formatted = []; + foreach ( $pages as $page ) { + $pages_formatted[] = [ + 'id' => $page->ID, + 'title' => $page->post_title, + ]; + } + + // Page type descriptions for SettingsStep. + // Use __() instead of esc_html__() to avoid HTML entity encoding in JSON. + $page_types = [ + 'homepage' => [ + 'id' => 'homepage', + 'title' => \__( 'Home page', 'progress-planner' ), + 'description' => \__( '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' => \__( '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' => \__( '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' => \__( '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 settings step. + $post_types = \progress_planner()->get_settings()->get_public_post_types(); + $post_types_formatted = []; + foreach ( $post_types as $post_type ) { + $post_type_obj = \get_post_type_object( $post_type ); + if ( $post_type_obj ) { + $post_types_formatted[] = [ + 'id' => $post_type, + 'title' => $post_type_obj->labels->name, + ]; + } + } + + // Check skip_onboarding flag. + $is_branded = 0 !== (int) \progress_planner()->get_ui__branding()->get_branding_id(); + $skip_onboarding = \progress_planner()->is_privacy_policy_accepted() + && ! \get_option( 'prpl_onboard_progress', false ) + && ! $is_branded; + $skip_onboarding = \apply_filters( 'progress_planner_skip_onboarding', $skip_onboarding ); + + // Capture logo HTML. + \ob_start(); + \progress_planner()->get_ui__branding()->the_logo(); + $logo_html = \ob_get_clean(); + + // Get current user data for email frequency step. + $current_user = \wp_get_current_user(); + + $config = [ + 'enabled' => ! $skip_onboarding, + 'steps' => $steps_formatted, + 'savedProgress' => $saved_progress, + 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( 'progress_planner' ), + 'nonceWPAPI' => \wp_create_nonce( 'wp_rest' ), + 'userFirstName' => $current_user->first_name ? $current_user->first_name : $current_user->display_name, + 'userEmail' => $current_user->user_email, + '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() ) ), + 'timezoneOffset' => (float) ( \wp_timezone()->getOffset( new \DateTime( 'midnight' ) ) / 3600 ), + 'lastStepRedirectUrl' => \esc_url_raw( \admin_url( 'admin.php?page=progress-planner' ) ), + 'hasLicense' => false !== \progress_planner()->get_license_key(), + 'pages' => $pages_formatted, + 'postTypes' => $post_types_formatted, + 'pageTypes' => $page_types, + 'logoHtml' => $logo_html, + 'baseUrl' => \constant( 'PROGRESS_PLANNER_URL' ), + 'privacyPolicyUrl' => \progress_planner()->get_ui__branding()->get_url( 'https://progressplanner.com/privacy-policy/#h-plugin-privacy-policy' ), + '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() ) ), + 'dashboard' => \esc_html__( 'Take me to the dashboard', 'progress-planner' ), + 'backToRecommendations' => \esc_html__( 'Back to recommendations', 'progress-planner' ), + ], + ]; + + return new \WP_REST_Response( $config, 200 ); + } +} diff --git a/classes/admin/widgets/class-activity-scores.php b/classes/rest/widgets/class-activity-scores.php similarity index 57% rename from classes/admin/widgets/class-activity-scores.php rename to classes/rest/widgets/class-activity-scores.php index 8adcde6448..bc1ced8ad6 100644 --- a/classes/admin/widgets/class-activity-scores.php +++ b/classes/rest/widgets/class-activity-scores.php @@ -1,66 +1,123 @@ 'GET', + 'callback' => [ $this, 'get_activity_scores' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'range' => [ + 'type' => 'string', + 'default' => '-6 months', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'frequency' => [ + 'type' => 'string', + 'default' => 'monthly', + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ], + ] + ); + } /** - * The color callback. + * Check if the current user has permission to access this endpoint. * - * @param int $number The number to calculate the color for. - * @param \DateTime $date The date. + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'edit_posts' ); + } + + /** + * Get activity scores data. + * + * @param \WP_REST_Request $request The REST request object. * - * @return string The color. + * @return \WP_REST_Response */ - public function get_color( $number, $date ) { - // If monthly and the latest month, return gray (in progress). - if ( - 'monthly' === $this->get_frequency() && - \gmdate( 'Y-m-01' ) === $date->format( 'Y-m-01' ) - ) { - return 'var(--prpl-color-border)'; - } + public function get_activity_scores( $request ) { + $range = $request->get_param( 'range' ) ?? '-6 months'; + $frequency = $request->get_param( 'frequency' ) ?? 'monthly'; - // If weekly and the current week, return gray (in progress). - if ( - 'weekly' === $this->get_frequency() && - \gmdate( 'Y-W' ) === $date->format( 'Y-W' ) - ) { - return 'var(--prpl-color-border)'; - } + $score = $this->get_score(); + $record = $this->personal_record_callback(); + $checklist = $this->get_checklist_results(); - if ( $number > 90 ) { - return 'var(--prpl-graph-color-3)'; - } - if ( $number > 30 ) { - return 'var(--prpl-color-monthly)'; - } - return 'var(--prpl-graph-color-1)'; + // Get chart data. + $chart = \progress_planner()->get_ui__chart(); + $start_date = \DateTime::createFromFormat( 'Y-m-d', \gmdate( 'Y-m-01' ) )->modify( $range ); + $end_date = new \DateTime(); + + $chart_data = $chart->get_chart_data( + [ + 'type' => 'bar', + 'items_callback' => fn( $start_date, $end_date ) => \progress_planner()->get_activities__query()->query_activities( + [ + 'start_date' => $start_date, + 'end_date' => $end_date, + ] + ), + 'dates_params' => [ + 'start_date' => $start_date, + 'end_date' => $end_date, + 'frequency' => $frequency, + 'format' => 'M', + ], + 'count_callback' => fn( $activities, $date ) => \array_sum( \array_map( fn( $activity ) => $activity->get_points( $date ), $activities ) ) * 100 / Plugin_Base::SCORE_TARGET, + 'normalized' => true, + 'max' => 100, + 'return_data' => [ 'label', 'score' ], // Don't return color - that's presentation logic. + ] + ); + + // Build response data. + $response_data = [ + 'score' => $score, + 'chartData' => $chart_data, + 'personalRecord' => [ + 'maxStreak' => (int) $record['max_streak'], + 'currentStreak' => (int) $record['current_streak'], + ], + 'checklist' => $checklist, + ]; + + return new \WP_REST_Response( $response_data ); } /** @@ -68,7 +125,7 @@ public function get_color( $number, $date ) { * * @return int The score. */ - public function get_score() { + private function get_score() { $activities = \progress_planner()->get_activities__query()->query_activities( // Use 31 days to take into account the activities score decay from previous activities. [ 'start_date' => new \DateTime( '-31 days' ) ] @@ -94,7 +151,7 @@ public function get_score() { * * @return array The checklist results. */ - public function get_checklist_results() { + private function get_checklist_results() { $items = $this->get_checklist(); $results = []; foreach ( $items as $item ) { @@ -109,7 +166,7 @@ public function get_checklist_results() { * * @return array The checklist items. */ - public function get_checklist() { + private function get_checklist() { return [ [ 'label' => \esc_html__( 'published content', 'progress-planner' ), @@ -144,29 +201,12 @@ public function get_checklist() { ]; } - /** - * Get the gauge color. - * - * @param int $score The score. - * - * @return string The color. - */ - public function get_gauge_color( $score ) { - if ( $score >= 75 ) { - return 'var(--prpl-graph-color-3)'; - } - if ( $score >= 50 ) { - return 'var(--prpl-color-monthly)'; - } - return 'var(--prpl-graph-color-1)'; - } - /** * Get the personal record goal. * * @return array */ - public function personal_record_callback() { + private function personal_record_callback() { $goal = Goal_Recurring::get_instance( 'weekly_post_record', [ @@ -178,7 +218,7 @@ public function personal_record_callback() { 'priority' => 'low', 'evaluate' => function ( $goal_object ) { // Get the cached activities. - $cached_activities = \progress_planner()->get_settings()->get( $this->cache_key, [] ); + $cached_activities = \progress_planner()->get_settings()->get( self::CACHE_KEY, [] ); // Get the weekly cache key. $weekly_cache_key = $goal_object->get_details()['start_date']->format( 'Y-m-d' ) . '_' . $goal_object->get_details()['end_date']->format( 'Y-m-d' ); @@ -200,7 +240,7 @@ public function personal_record_callback() { // Cache the activities. $cached_activities[ $weekly_cache_key ] = (bool) \count( $activities ); - \progress_planner()->get_settings()->set( $this->cache_key, $cached_activities ); + \progress_planner()->get_settings()->set( self::CACHE_KEY, $cached_activities ); // Return the cached value. return $cached_activities[ $weekly_cache_key ]; @@ -216,13 +256,4 @@ public function personal_record_callback() { return $goal->get_streak(); } - - /** - * Get the cache key. - * - * @return string The cache key. - */ - public function get_cache_key() { - return $this->cache_key; - } } diff --git a/classes/rest/widgets/class-content-activity.php b/classes/rest/widgets/class-content-activity.php new file mode 100644 index 0000000000..e84b3b7ad1 --- /dev/null +++ b/classes/rest/widgets/class-content-activity.php @@ -0,0 +1,238 @@ + 'GET', + 'callback' => [ $this, 'get_content_activity' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [ + 'range' => [ + 'type' => 'string', + 'default' => '-6 months', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'frequency' => [ + 'type' => 'string', + 'default' => 'monthly', + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'edit_posts' ); + } + + /** + * Get content activity data. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_content_activity( $request ) { + $range = $request->get_param( 'range' ) ?? '-6 months'; + $frequency = $request->get_param( 'frequency' ) ?? 'monthly'; + + // Activity types configuration. + $activity_types = [ + 'publish' => [ + 'label' => \__( 'published', 'progress-planner' ), + 'color' => 'var(--prpl-color-monthly)', + ], + 'update' => [ + 'label' => \__( 'updated', 'progress-planner' ), + 'color' => 'var(--prpl-graph-color-3)', + ], + 'delete' => [ + 'label' => \__( 'deleted', 'progress-planner' ), + 'color' => 'var(--prpl-color-headings)', + ], + ]; + + $tracked_post_types = \progress_planner()->get_activities__content_helpers()->get_post_types_names(); + + // Prepare chart data and options. + $chart_data = []; + $chart_options = [ + 'dataArgs' => [], + 'chartId' => 'prpl-chart-content-activity', + 'axisColor' => 'var(--prpl-color-border)', + 'rulersColor' => 'var(--prpl-color-border)', + 'filtersLabel' => '' . \__( 'show:', 'progress-planner' ) . '', + ]; + + $start_date = \DateTime::createFromFormat( 'Y-m-d', \gmdate( 'Y-m-01' ) )->modify( $range ); + $end_date = new \DateTime(); + + foreach ( $activity_types as $activity_type => $activity_data ) { + $chart_data[ $activity_type ] = \progress_planner() + ->get_ui__chart() + ->get_chart_data( + $this->get_chart_args_content_count( + $start_date, + $end_date, + $frequency, + $tracked_post_types, + $activity_type, + $activity_data['color'] + ) + ); + + $chart_options['dataArgs'][ $activity_type ] = [ + 'color' => $activity_data['color'], + 'label' => $activity_data['label'], + ]; + } + + // Calculate weekly activity counts. + $weekly_activity = []; + $weekly_total = 0; + + foreach ( \array_keys( $activity_types ) as $activity_type ) { + $weekly_activity[ $activity_type ] = 0; + + $activities = \progress_planner()->get_activities__query()->query_activities( + [ + 'category' => 'content', + 'start_date' => \gmdate( 'Y-m-d', \strtotime( '-1 week' ) ), + 'end_date' => \gmdate( 'Y-m-d' ), + 'type' => $activity_type, + ] + ); + + if ( $activities ) { + if ( 'delete' !== $activity_type ) { + // Filter to only include tracked post types. + $activities = \array_filter( + $activities, + fn( $activity ) => \in_array( + \get_post_type( $activity->data_id ), + $tracked_post_types, + true + ) + ); + } + + $weekly_activity[ $activity_type ] = \count( $activities ); + } + + $weekly_total += $weekly_activity[ $activity_type ]; + } + + // Response data. + $response_data = [ + 'chartData' => $chart_data, + 'chartOptions' => $chart_options, + 'activityTypes' => $activity_types, + 'weeklyActivity' => $weekly_activity, + 'weeklyTotalCount' => $weekly_total, + 'totalCount' => \number_format_i18n( $weekly_total ), + 'i18n' => [ + 'title' => \__( 'Content activity', 'progress-planner' ), + 'description' => \__( 'Here are the updates you made to your content last week. Whether you published something new, updated an existing post, or removed outdated content, it all helps you stay on top of your site!', 'progress-planner' ), + 'piecesOfContentManaged' => \__( 'pieces of content managed', 'progress-planner' ), + 'contentManaged' => \__( 'Content managed', 'progress-planner' ), + 'lastWeek' => \__( 'Last week', 'progress-planner' ), + 'total' => \__( 'Total', 'progress-planner' ), + 'show' => \__( 'show:', 'progress-planner' ), + ], + ]; + + return new \WP_REST_Response( $response_data ); + } + + /** + * Get the chart args for content count. + * + * @param \DateTime $start_date The start date. + * @param \DateTime $end_date The end date. + * @param string $frequency The frequency. + * @param array $tracked_post_types The tracked post types. + * @param string $type The type of activity. + * @param string $color The color of the chart. + * + * @return array The chart args. + */ + private function get_chart_args_content_count( $start_date, $end_date, $frequency, $tracked_post_types, $type = 'publish', $color = '#534786' ) { + return [ + 'type' => 'line', + 'items_callback' => fn( $start_date, $end_date ) => \progress_planner()->get_activities__query()->query_activities( + [ + 'category' => 'content', + 'start_date' => $start_date, + 'end_date' => $end_date, + 'type' => $type, + ] + ), + 'dates_params' => [ + 'start_date' => $start_date, + 'end_date' => $end_date, + 'frequency' => $frequency, + 'format' => 'M', + ], + 'filter_results' => function ( $activities ) use ( $type, $tracked_post_types ) { + return $this->filter_activities( $activities, $type, $tracked_post_types ); + }, + 'count_callback' => fn( $activities ) => \count( $activities ), + 'return_data' => [ 'label', 'score' ], // Don't return color - that's presentation logic. + ]; + } + + /** + * Callback to filter the activities. + * + * @param array $activities The activities array. + * @param string $type The activity type. + * @param array $tracked_post_types The tracked post types. + * + * @return array The filtered activities. + */ + private function filter_activities( $activities, $type, $tracked_post_types ) { + return \array_filter( + $activities, + function ( $activity ) use ( $type, $tracked_post_types ) { + if ( 'delete' === $type ) { + return true; + } + $post = $activity->get_post(); + if ( ! \is_object( $post ) ) { + return false; + } + $post_type = \get_post_type( $post ); + return \in_array( $post_type, $tracked_post_types, true ); + } + ); + } +} diff --git a/classes/admin/widgets/class-whats-new.php b/classes/rest/widgets/class-whats-new.php similarity index 82% rename from classes/admin/widgets/class-whats-new.php rename to classes/rest/widgets/class-whats-new.php index 6a4b921a74..1d9b7e4461 100644 --- a/classes/admin/widgets/class-whats-new.php +++ b/classes/rest/widgets/class-whats-new.php @@ -1,39 +1,104 @@ 'GET', + 'callback' => [ $this, 'get_whats_new' ], + 'permission_callback' => [ $this, 'check_permissions' ], + ], + ] + ); + } + + /** + * Check if the current user has permission to access this endpoint. + * + * @return bool + */ + public function check_permissions() { + return \current_user_can( 'edit_posts' ); + } + + /** + * Get what's new data. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response + */ + public function get_whats_new( $request ) { + $posts = $this->get_blog_feed(); + + // Return empty array if no posts. + if ( empty( $posts ) ) { + return new \WP_REST_Response( + [ + 'posts' => [], + 'blogUrl' => '', + ] + ); + } + + // Format posts for frontend. + $formatted_posts = \array_map( + function ( $post ) { + $image_url = $post['featured_media']['media_details']['sizes']['medium_large']['source_url'] ?? null; + return [ + 'title' => $post['title']['rendered'], + 'link' => $post['link'], + 'excerpt' => \wp_trim_words( \wp_strip_all_tags( $post['content']['rendered'] ), 55 ), + 'imageUrl' => $image_url, + ]; + }, + $posts + ); + + return new \WP_REST_Response( + [ + 'posts' => $formatted_posts, + 'blogUrl' => \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/blog' ), + ] + ); + } /** * Get the feed from the blog. * * @return array */ - public function get_blog_feed() { - $feed_data = \progress_planner()->get_utils__cache()->get( $this->get_cache_key() ); + private function get_blog_feed() { + $cache_key = $this->get_cache_key(); + $feed_data = \progress_planner()->get_utils__cache()->get( $cache_key ); // Migrate old feed to new format. if ( \is_array( $feed_data ) && ! isset( $feed_data['expires'] ) && ! isset( $feed_data['feed'] ) ) { $feed_data = [ 'feed' => $feed_data, - 'expires' => \get_option( '_transient_timeout_' . Cache::CACHE_PREFIX . $this->get_cache_key(), 0 ), + 'expires' => \get_option( '_transient_timeout_' . Cache::CACHE_PREFIX . $cache_key, 0 ), ]; } @@ -76,12 +141,21 @@ public function get_blog_feed() { } // Transient uses 'expires' key to determine if it's expired. - \progress_planner()->get_utils__cache()->set( $this->get_cache_key(), $feed_data, 0 ); + \progress_planner()->get_utils__cache()->set( $cache_key, $feed_data, 0 ); } return $feed_data['feed']; } + /** + * Get the cache key. + * + * @return string + */ + private function get_cache_key() { + return 'blog_feed_' . \md5( \progress_planner()->get_ui__branding()->get_blog_feed_url() ); + } + /** * Parse an RSS feed item and convert it to the expected format. * @@ -209,14 +283,8 @@ private function extract_featured_image( $item ) { /** * Extract image URL from HTML content. * - * Checks for various HTML elements and attributes that can contain images: - * - tags (src, srcset, data-src) - * - elements (source tags and img fallback) - * -
    elements - * - Background images in style attributes - * * @param string $html The HTML content to parse. - * @param bool $prioritize_featured Whether to prioritize featured images (wp-post-thumbnail, wp-post-image). + * @param bool $prioritize_featured Whether to prioritize featured images. * @return string|null The first image URL found, or null if none found. */ private function extract_image_from_html( $html, $prioritize_featured = false ) { @@ -323,13 +391,4 @@ private function get_featured_image_from_rest_api( $permalink ) { return null; } - - /** - * Get the cache key. - * - * @return string - */ - public function get_cache_key() { - return 'blog_feed_' . \md5( \progress_planner()->get_ui__branding()->get_blog_feed_url() ); - } } diff --git a/classes/suggested-tasks/class-tasks-interface.php b/classes/suggested-tasks/class-tasks-interface.php deleted file mode 100644 index bf9a9ca598..0000000000 --- a/classes/suggested-tasks/class-tasks-interface.php +++ /dev/null @@ -1,142 +0,0 @@ -task_providers = [ - new Content_Create(), - new Content_Review(), - new Core_Update(), - new Blog_Description(), - new Debug_Display(), - new Disable_Comments(), - new Disable_Comment_Pagination(), - new Sample_Page(), - new Hello_World(), - new Remove_Inactive_Plugins(), - new Site_Icon(), - new Rename_Uncategorized_Category(), - new Permalink_Structure(), - new Php_Version(), - new Search_Engine_Visibility(), - new Reduce_Autoloaded_Options(), - new User_Tasks(), - new Email_Sending(), - new Set_Valuable_Post_Types(), - new Select_Locale(), - new Remove_Terms_Without_Posts(), - new Fewer_Tags(), - new Update_Term_Description(), - new Unpublished_Content(), - new Collaborator(), - new Select_Timezone(), - new Set_Date_Format(), - new SEO_Plugin(), - new Improve_Pdf_Handling(), - new Set_Page_About(), - new Set_Page_FAQ(), - new Set_Page_Contact(), - ]; - - // Add the plugin integration. - \add_action( 'plugins_loaded', [ $this, 'add_plugin_integration' ] ); - - // At this point both local and task providers for the plugins we integrate with are instantiated, so initialize them. - \add_action( 'init', [ $this, 'init' ], 99 ); // Wait for the post types to be initialized. - - // Add the cleanup action. - \add_action( 'admin_init', [ $this, 'cleanup_pending_tasks' ] ); - - // Handle tasks when snoozed period is over. - \add_action( 'transition_post_status', [ $this, 'handle_task_unsnooze' ], 10, 3 ); - } - - /** - * Add the plugin integrations if the plugins are active. - * - * @return void - */ - public function add_plugin_integration() { - // Yoast SEO integration. - new Add_Yoast_Providers(); - - // All in One SEO integration. - new Add_AIOSEO_Providers(); - } - - /** - * Initialize the task providers. - * - * @return void - */ - public function init() { - /** - * Filter the task providers, 3rd party providers are added here as well. - * - * @param array $task_providers The task providers. - */ - $this->task_providers = \apply_filters( 'progress_planner_suggested_tasks_providers', $this->task_providers ); - - // Now when all are instantiated, initialize them. - foreach ( $this->task_providers as $key => $task_provider ) { - if ( ! $task_provider instanceof Tasks_Interface ) { - \error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - \sprintf( - 'Task provider %1$s is not an instance of %2$s', - $task_provider->get_provider_id(), - Tasks_Interface::class - ) - ); - unset( $this->task_providers[ $key ] ); - - continue; - } - - // Initialize the task provider (add hooks, etc.). - $task_provider->init(); - } - - $this->inject_tasks(); - - // Add the onboarding task providers. - \add_filter( 'prpl_onboarding_task_providers', [ $this, 'add_onboarding_task_providers' ] ); - } - - /** - * Add the onboarding task providers. - * - * @param array $task_providers The task providers. - * - * @return array - */ - public function add_onboarding_task_providers( $task_providers ) { - foreach ( $this->task_providers as $task_provider ) { - if ( $task_provider->is_onboarding_task() ) { - $task_providers[] = $task_provider->get_provider_id(); - } - } - - return $task_providers; - } - - /** - * Get a task provider. - * - * @param string $name The method name. - * @param array $arguments The arguments. - * - * @return \Progress_Planner\Suggested_Tasks\Tasks_Interface|null - */ - public function __call( $name, $arguments ) { - if ( 0 === \strpos( $name, 'get_' ) ) { - $provider_type = \substr( $name, 4 ); // Remove 'get_' prefix. - $provider_type = \str_replace( '_', '-', \strtolower( $provider_type ) ); // Transform 'update_core' to 'update-core'. - - return $this->get_task_provider( $provider_type ); - } - - return null; - } - - /** - * Get the task providers. - * - * @return array - */ - public function get_task_providers() { - return $this->task_providers; - } - - /** - * Get the user available task providers, based on the capability required and the user role. - * - * @return array - */ - public function get_task_providers_available_for_user() { - return \array_filter( - $this->task_providers, - function ( $task_provider ) { - return $task_provider->capability_required(); - } - ); - } - - /** - * Get a task provider by its ID. - * - * @param string $provider_id The provider ID. - * - * @return \Progress_Planner\Suggested_Tasks\Tasks_Interface|null - */ - public function get_task_provider( $provider_id ) { - foreach ( $this->task_providers as $provider_instance ) { - if ( $provider_instance->get_provider_id() === $provider_id ) { - return $provider_instance; - } - } - - return null; - } - - /** - * Inject tasks. - * - * @return void - */ - public function inject_tasks() { - // Loop through all registered task providers and inject their tasks. - foreach ( $this->task_providers as $provider_instance ) { - // WIP, get_tasks_to_inject() is injecting tasks. - $provider_instance->get_tasks_to_inject(); - } - } - - /** - * Evaluate tasks stored in the option. - * - * @return \Progress_Planner\Suggested_Tasks\Task[] - */ - public function evaluate_tasks(): array { - $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'publish' ] ); - $completed_tasks = []; - - foreach ( $tasks as $task ) { - $task_result = $this->evaluate_task( $task ); - if ( false !== $task_result ) { - $completed_tasks[] = $task_result; - } - } - - return $completed_tasks; - } - - /** - * Evaluate a task. - * - * @param \Progress_Planner\Suggested_Tasks\Task $task The task to evaluate. - * - * @return \Progress_Planner\Suggested_Tasks\Task|false - */ - public function evaluate_task( Task $task ) { - // User tasks are not evaluated. - if ( \has_term( 'user', 'prpl_recommendations_provider', $task->ID ) ) { - return false; - } - - if ( ! $task->provider ) { - return false; - } - $task_provider = $this->get_task_provider( $task->provider->slug ); - if ( ! $task_provider || ! $task_provider->is_task_relevant() ) { - // Remove the task from the published tasks. - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - return false; - } - - return $task_provider->evaluate_task( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ); - } - - /** - * Remove all tasks which have date set to the previous week. - * Tasks for the current week will be added automatically. - * - * @return void - */ - public function cleanup_pending_tasks() { - if ( \progress_planner()->get_utils__cache()->get( 'cleanup_pending_tasks' ) ) { - return; - } - - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'publish' ] ); - - foreach ( $tasks as $task ) { - // Skip user tasks. - if ( 'user' === $task->get_provider_id() ) { - continue; - } - - $task_provider = $this->get_task_provider( $task->get_provider_id() ); - - // Should we delete the task? Delete tasks which don't have a task provider or repetitive tasks which were created in the previous week. - if ( ! $task_provider || ( $task_provider->is_repetitive() && ( ! $task->date || \gmdate( 'oW' ) !== (string) $task->date ) ) ) { - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - } - } - - \progress_planner()->get_utils__cache()->set( 'cleanup_pending_tasks', true, DAY_IN_SECONDS ); - } - - /** - * Handle task unsnooze. - * - * @param string $new_status The new status. - * @param string $old_status The old status. - * @param \WP_Post $post The post. - * - * @return void - */ - public function handle_task_unsnooze( $new_status, $old_status, $post ) { - // Early exit if it's not task for which snooze period is over. - if ( 'future' !== $old_status || 'publish' !== $new_status || 'prpl_recommendations' !== \get_post_type( $post ) ) { - return; - } - - $task = \progress_planner()->get_suggested_tasks_db()->get_post( $post->ID ); - if ( ! $task ) { - return; - } - - $task_provider = $this->get_task_provider( $task->get_provider_id() ); - - // Delete tasks which don't have a task provider or repetitive tasks which were created in the previous week. - if ( ! $task_provider || ( $task_provider->is_repetitive() && ( ! $task->date || \gmdate( 'oW' ) !== (string) $task->date ) ) ) { - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - } - - // Delete the task if it is not relevant anymore. - if ( $task_provider instanceof \Progress_Planner\Suggested_Tasks\Providers\Tasks && ! $task_provider->should_add_task() ) { - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - } - } -} diff --git a/classes/suggested-tasks/data-collector/class-aioseo-options.php b/classes/suggested-tasks/data-collector/class-aioseo-options.php new file mode 100644 index 0000000000..2de4c303ae --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-aioseo-options.php @@ -0,0 +1,122 @@ +|null AIOSEO options or null if AIOSEO is not active. + */ + protected function calculate_data() { + // Return null if AIOSEO is not active. + if ( ! \function_exists( 'aioseo' ) ) { + return null; + } + + return [ + 'schema' => $this->get_schema_options(), + 'archives' => $this->get_archives_options(), + 'attachment' => $this->get_attachment_options(), + 'crawlCleanup' => $this->get_crawl_cleanup_options(), + ]; + } + + /** + * Get schema options. + * + * @return array Schema options. + */ + private function get_schema_options(): array { + $schema = \aioseo()->options->searchAppearance->global->schema; // @phpstan-ignore-line + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- AIOSEO uses camelCase. + return [ + 'siteRepresents' => $schema->siteRepresents ?? 'organization', // @phpstan-ignore-line + 'organizationLogo' => $schema->organizationLogo ?? '', // @phpstan-ignore-line + 'personLogo' => $schema->personLogo ?? '', // @phpstan-ignore-line + ]; + // phpcs:enable + } + + /** + * Get archives options. + * + * @return array Archives options. + */ + private function get_archives_options(): array { + $archives = \aioseo()->options->searchAppearance->archives; // @phpstan-ignore-line + + return [ + 'author' => [ + 'show' => $archives->author->show ?? true, // @phpstan-ignore-line + ], + 'date' => [ + 'show' => $archives->date->show ?? true, // @phpstan-ignore-line + ], + ]; + } + + /** + * Get attachment/media options. + * + * @return array Attachment options. + */ + private function get_attachment_options(): array { + $attachment = \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment; // @phpstan-ignore-line + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- AIOSEO uses camelCase. + return [ + 'redirectAttachmentUrls' => $attachment->redirectAttachmentUrls ?? 'disabled', // @phpstan-ignore-line + ]; + // phpcs:enable + } + + /** + * Get crawl cleanup options. + * + * @return array Crawl cleanup options. + */ + private function get_crawl_cleanup_options(): array { + $feeds = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds; // @phpstan-ignore-line + + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- AIOSEO uses camelCase. + return [ + 'feeds' => [ + 'authors' => $feeds->authors ?? true, // @phpstan-ignore-line + 'globalComments' => $feeds->globalComments ?? true, // @phpstan-ignore-line + 'postComments' => $feeds->postComments ?? true, // @phpstan-ignore-line + ], + ]; + // phpcs:enable + } +} diff --git a/classes/suggested-tasks/data-collector/class-data-collector-manager.php b/classes/suggested-tasks/data-collector/class-data-collector-manager.php index 8ce2c520fb..8058466c67 100644 --- a/classes/suggested-tasks/data-collector/class-data-collector-manager.php +++ b/classes/suggested-tasks/data-collector/class-data-collector-manager.php @@ -21,6 +21,13 @@ use Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Orphaned_Content; use Progress_Planner\Suggested_Tasks\Data_Collector\Unpublished_Content; use Progress_Planner\Suggested_Tasks\Data_Collector\SEO_Plugin; +use Progress_Planner\Suggested_Tasks\Data_Collector\PHP_Version; +use Progress_Planner\Suggested_Tasks\Data_Collector\WP_Debug; +use Progress_Planner\Suggested_Tasks\Data_Collector\Old_Posts_For_Review; +use Progress_Planner\Suggested_Tasks\Data_Collector\Permalink_Has_Date; +use Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Options; +use Progress_Planner\Suggested_Tasks\Data_Collector\Yoast_Premium_Status; +use Progress_Planner\Suggested_Tasks\Data_Collector\AIOSEO_Options; /** * Base data collector. @@ -54,6 +61,10 @@ public function __construct() { new Published_Post_Count(), new Unpublished_Content(), new SEO_Plugin(), + new PHP_Version(), + new WP_Debug(), + new Old_Posts_For_Review(), + new Permalink_Has_Date(), ]; // Add the plugin integration. @@ -75,6 +86,13 @@ public function add_plugin_integration() { // Yoast SEO integration. if ( \function_exists( 'YoastSEO' ) ) { $this->data_collectors[] = new Yoast_Orphaned_Content(); + $this->data_collectors[] = new Yoast_Options(); + $this->data_collectors[] = new Yoast_Premium_Status(); + } + + // AIOSEO integration. + if ( \function_exists( 'aioseo' ) ) { + $this->data_collectors[] = new AIOSEO_Options(); } } diff --git a/classes/suggested-tasks/data-collector/class-old-posts-for-review.php b/classes/suggested-tasks/data-collector/class-old-posts-for-review.php new file mode 100644 index 0000000000..57024dca18 --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-old-posts-for-review.php @@ -0,0 +1,169 @@ + Array of post data. + */ + protected function calculate_data() { + $posts_to_review = []; + + // Get important page IDs. + $important_page_ids = $this->get_important_page_ids(); + + // Check important pages (6 months threshold). + if ( ! empty( $important_page_ids ) ) { + $important_posts = $this->get_old_posts( + [ + 'post__in' => $important_page_ids, + 'post_type' => 'any', + 'date_query' => [ + [ + 'column' => 'post_modified', + 'before' => '-6 months', + ], + ], + ] + ); + + foreach ( $important_posts as $post ) { + $posts_to_review[] = $this->format_post_data( $post ); + } + } + + // Get remaining slots for regular posts (12 months threshold). + $remaining_slots = static::ITEMS_TO_RETURN - \count( $posts_to_review ); + + if ( $remaining_slots > 0 ) { + $include_post_types = \progress_planner()->get_settings()->get_post_types_names(); + + $regular_posts = $this->get_old_posts( + [ + 'posts_per_page' => $remaining_slots, + 'post__not_in' => $important_page_ids, + 'post_type' => $include_post_types, + 'date_query' => [ + [ + 'column' => 'post_modified', + 'before' => '-12 months', + ], + ], + ] + ); + + foreach ( $regular_posts as $post ) { + $posts_to_review[] = $this->format_post_data( $post ); + } + } + + return $posts_to_review; + } + + /** + * Get important page IDs. + * + * Includes pages set in page settings and the privacy policy page. + * + * @return int[] Array of page IDs. + */ + protected function get_important_page_ids() { + $important_page_ids = []; + + // Get pages from page settings (about, contact, FAQ). + foreach ( \progress_planner()->get_admin__page_settings()->get_settings() as $page_setting ) { + if ( 0 !== (int) $page_setting['value'] ) { + $important_page_ids[] = (int) $page_setting['value']; + } + } + + // Add privacy policy page. + $privacy_policy_page_id = \get_option( 'wp_page_for_privacy_policy' ); + if ( $privacy_policy_page_id ) { + $important_page_ids[] = (int) $privacy_policy_page_id; + } + + /** + * Filters the pages we deem more important for content updates. + * + * @param int[] $important_page_ids Post & page IDs of the important pages. + */ + return \apply_filters( 'progress_planner_update_posts_important_page_ids', $important_page_ids ); + } + + /** + * Get old posts based on args. + * + * @param array $args Query arguments. + * @return \WP_Post[] Array of posts. + */ + protected function get_old_posts( $args = [] ) { + $args = \wp_parse_args( + $args, + [ + 'posts_per_page' => static::ITEMS_TO_RETURN, + 'post_status' => 'publish', + 'orderby' => 'modified', + 'order' => 'ASC', + 'ignore_sticky_posts' => true, + ] + ); + + /** + * Filters the args for the posts & pages we want user to review. + * + * @param array $args The get_posts args. + */ + $args = \apply_filters( 'progress_planner_update_posts_tasks_args', $args ); + + $posts = \get_posts( $args ); + + return $posts ? $posts : []; + } + + /** + * Format post data for API response. + * + * @param \WP_Post $post The post object. + * @return array{ID: int, post_title: string, post_type: string, post_modified: string} Formatted post data. + */ + protected function format_post_data( $post ) { + return [ + 'ID' => $post->ID, + 'post_title' => $post->post_title, + 'post_type' => $post->post_type, + 'post_modified' => $post->post_modified, + ]; + } +} diff --git a/classes/suggested-tasks/data-collector/class-permalink-has-date.php b/classes/suggested-tasks/data-collector/class-permalink-has-date.php new file mode 100644 index 0000000000..d0498712eb --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-permalink-has-date.php @@ -0,0 +1,53 @@ +seo_plugins as $plugin_data ) { // First, check if the plugin is activated by slug. - if ( \progress_planner()->get_plugin_installer()->is_plugin_activated( $plugin_data['slug'] ) ) { + if ( \Progress_Planner\Utils\Plugin_Utils::is_plugin_activated( $plugin_data['slug'] ) ) { return true; } diff --git a/classes/suggested-tasks/data-collector/class-wp-debug.php b/classes/suggested-tasks/data-collector/class-wp-debug.php new file mode 100644 index 0000000000..2966d6e62b --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-wp-debug.php @@ -0,0 +1,41 @@ + $wp_debug, + 'wp_debug_display' => $wp_debug_display, + 'should_fix' => $wp_debug && $wp_debug_display, + ]; + } +} diff --git a/classes/suggested-tasks/data-collector/class-yoast-options.php b/classes/suggested-tasks/data-collector/class-yoast-options.php new file mode 100644 index 0000000000..3e5fc1c7a8 --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-yoast-options.php @@ -0,0 +1,73 @@ +|null Yoast options or null if Yoast is not active. + */ + protected function calculate_data() { + // Return null if Yoast SEO is not active. + if ( ! \function_exists( 'YoastSEO' ) ) { + return null; + } + + $wpseo = \get_option( 'wpseo', [] ); + $wpseo_titles = \get_option( 'wpseo_titles', [] ); + + return [ + 'wpseo' => $wpseo, + 'wpseo_titles' => $wpseo_titles, + 'site_logo' => $this->get_site_logo_id(), + ]; + } + + /** + * Get the site logo ID. + * + * Checks both the site_logo option and the custom_logo theme mod. + * Yoast SEO uses the site logo as a fallback for organization/person logo. + * + * @return int The site logo attachment ID, or 0 if not set. + */ + private function get_site_logo_id(): int { + $site_logo_id = \get_option( 'site_logo' ); + if ( ! $site_logo_id ) { + $site_logo_id = \get_theme_mod( 'custom_logo', false ); + } + return (int) $site_logo_id; + } +} diff --git a/classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php b/classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php index 691325c1c8..ef5b25d65d 100644 --- a/classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php +++ b/classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php @@ -8,8 +8,6 @@ namespace Progress_Planner\Suggested_Tasks\Data_Collector; use Progress_Planner\Suggested_Tasks\Data_Collector\Base_Data_Collector; -use Progress_Planner\Suggested_Tasks\Data_Collector\Hello_World; -use Progress_Planner\Suggested_Tasks\Data_Collector\Sample_Page; /** * Post author data collector class. diff --git a/classes/suggested-tasks/data-collector/class-yoast-premium-status.php b/classes/suggested-tasks/data-collector/class-yoast-premium-status.php new file mode 100644 index 0000000000..1b565c4fb9 --- /dev/null +++ b/classes/suggested-tasks/data-collector/class-yoast-premium-status.php @@ -0,0 +1,68 @@ + Premium status and workout data. + */ + protected function calculate_data() { + // Check if Yoast Premium is active. + $is_premium_active = \defined( 'WPSEO_PREMIUM_FILE' ) || \class_exists( 'WPSEO_Premium' ); + + $workouts = [ + 'cornerstone' => [ + 'finishedSteps' => [], + ], + 'orphaned' => [ + 'finishedSteps' => [], + ], + ]; + + // Get workout progress from wpseo_premium option. + $premium_options = \get_option( 'wpseo_premium', [] ); + if ( \is_array( $premium_options ) && isset( $premium_options['workouts'] ) && \is_array( $premium_options['workouts'] ) ) { + if ( isset( $premium_options['workouts']['cornerstone']['finishedSteps'] ) ) { + $workouts['cornerstone']['finishedSteps'] = $premium_options['workouts']['cornerstone']['finishedSteps']; + } + if ( isset( $premium_options['workouts']['orphaned']['finishedSteps'] ) ) { + $workouts['orphaned']['finishedSteps'] = $premium_options['workouts']['orphaned']['finishedSteps']; + } + } + + return [ + 'active' => $is_premium_active, + 'workouts' => $workouts, + ]; + } +} diff --git a/classes/suggested-tasks/providers/class-blog-description.php b/classes/suggested-tasks/providers/class-blog-description.php deleted file mode 100644 index add79362a3..0000000000 --- a/classes/suggested-tasks/providers/class-blog-description.php +++ /dev/null @@ -1,183 +0,0 @@ -get_task_id() ); - } - - /** - * Get the link setting. - * - * @return array - */ - public function get_link_setting() { - return [ - 'hook' => 'options-general.php', - 'iconEl' => 'th:has(+td #tagline-description)', - ]; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return '' === \get_bloginfo( 'description' ); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'In a few words, explain what this site is about. This information is used in your website\'s schema and RSS feeds, and can be displayed on your site. The tagline typically is your site\'s mission statement.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - ?> - -
    - -
    - 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 \__( '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-collaborator.php b/classes/suggested-tasks/providers/class-collaborator.php deleted file mode 100644 index e3d63b5d08..0000000000 --- a/classes/suggested-tasks/providers/class-collaborator.php +++ /dev/null @@ -1,104 +0,0 @@ -get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); - if ( empty( $tasks ) ) { - return false; - } - - $task_data = $tasks[0]->get_data(); - - return isset( $task_data['is_completed_callback'] ) && \is_callable( $task_data['is_completed_callback'] ) - ? \call_user_func( $task_data['is_completed_callback'], $task_id ) - : false; - } - - /** - * Get the task details. - * - * @param array $task_data The task data. - * - * @return array - */ - public function get_task_details( $task_data = [] ) { - $tasks = \progress_planner()->get_settings()->get( 'tasks', [] ); - - foreach ( $tasks as $task ) { - if ( $task['task_id'] !== $task_data['task_id'] ) { - continue; - } - - return \wp_parse_args( - $task, - [ - 'task_id' => '', - 'title' => '', - 'parent' => 0, - 'provider_id' => $this->get_provider_id(), - 'priority' => 'medium', - 'points' => 0, - 'url' => '', - 'url_target' => '_self', - 'description' => '', - 'link_setting' => [], - 'dismissable' => true, - 'external_link_url' => $this->get_external_link_url(), - ] - ); - } - - return []; - } -} diff --git a/classes/suggested-tasks/providers/class-content-create.php b/classes/suggested-tasks/providers/class-content-create.php deleted file mode 100644 index 94a72abbcb..0000000000 --- a/classes/suggested-tasks/providers/class-content-create.php +++ /dev/null @@ -1,131 +0,0 @@ -get_data_collector()->collect(); - - if ( ! $last_published_post_data || empty( $last_published_post_data['post_id'] ) ) { - return $task_data; - } - - // Add the post ID to the task data. - $task_data['target_post_id'] = $last_published_post_data['post_id']; - - return $task_data; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - // Get the post that was created last. - $last_published_post_data = $this->get_data_collector()->collect(); - - // There are no published posts, add task. - if ( ! $last_published_post_data || empty( $last_published_post_data['post_id'] ) ) { - return true; - } - - // Add tasks if there are no posts published this week. - return \gmdate( 'oW' ) !== \gmdate( 'oW', \strtotime( $last_published_post_data['post_date'] ) ); - } - - /** - * 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__( 'Create new post', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-content-review.php b/classes/suggested-tasks/providers/class-content-review.php deleted file mode 100644 index da23e60523..0000000000 --- a/classes/suggested-tasks/providers/class-content-review.php +++ /dev/null @@ -1,583 +0,0 @@ -include_post_types = \progress_planner()->get_settings()->get_post_types_names(); // Wait for the post types to be initialized. - - \add_filter( 'progress_planner_update_posts_tasks_args', [ $this, 'filter_update_posts_args' ] ); - - // Add the Yoast cornerstone pages to the important page IDs. - if ( \function_exists( 'YoastSEO' ) ) { - \add_filter( 'progress_planner_update_posts_important_page_ids', [ $this, 'add_yoast_cornerstone_pages' ] ); - } - - $this->init_dismissable_task(); - } - - /** - * Get the task title. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_title_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_post_id'] ) ) { - return ''; - } - - $post = \get_post( $task_data['target_post_id'] ); - - if ( ! $post ) { - return ''; - } - - return \sprintf( - // translators: %1$s: The post type, %2$s: The post title. - \esc_html__( 'Review %1$s "%2$s"', 'progress-planner' ), - \strtolower( \get_post_type_object( \esc_html( $post->post_type ) )->labels->singular_name ), // @phpstan-ignore-line property.nonObject - \esc_html( $post->post_title ) // @phpstan-ignore-line property.nonObject - ); - } - - /** - * Get the task URL. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_url_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_post_id'] ) ) { - return ''; - } - - $post = \get_post( $task_data['target_post_id'] ); - - if ( ! $post ) { - return ''; - } - - // We don't use the edit_post_link() function because we need to bypass it's current_user_can() check. - return \esc_url( - \add_query_arg( - [ - 'post' => $post->ID, - 'action' => 'edit', - ], - \admin_url( 'post.php' ) - ) - ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - if ( ! empty( $this->task_post_mappings ) ) { - return true; - } - - $last_updated_posts = []; - - // Check if there are any important pages to update. - $important_page_ids = []; - foreach ( \progress_planner()->get_admin__page_settings()->get_settings() as $important_page ) { - if ( 0 !== (int) $important_page['value'] ) { - $important_page_ids[] = (int) $important_page['value']; - } - } - - // Add the privacy policy page ID if it exists. Not 'publish' page will not be fetched by get_posts(). - $privacy_policy_page_id = \get_option( 'wp_page_for_privacy_policy' ); - if ( $privacy_policy_page_id ) { - $important_page_ids[] = (int) $privacy_policy_page_id; - } - - /** - * Filters the pages we deem more important for content updates. - * - * @param int[] $important_page_ids Post & page IDs of the important pages. - */ - $important_page_ids = \apply_filters( 'progress_planner_update_posts_important_page_ids', $important_page_ids ); - - if ( ! empty( $important_page_ids ) ) { - $last_updated_posts = $this->get_old_posts( - [ - 'post__in' => $important_page_ids, - 'post_type' => 'any', - 'date_query' => [ - [ - 'column' => 'post_modified', - 'before' => '-6 months', // Important pages are updated more often. - ], - ], - ] - ); - } - - // Lets check for other posts to update. - if ( 0 < static::ITEMS_TO_INJECT - \count( $last_updated_posts ) ) { - // Get the post that was updated last. - $last_updated_posts = \array_merge( - $last_updated_posts, - $this->get_old_posts( - [ - 'post__not_in' => $important_page_ids, // This can be an empty array. - 'post_type' => $this->include_post_types, - ] - ) - ); - } - - if ( ! $last_updated_posts ) { - return false; - } - - foreach ( $last_updated_posts as $post ) { - // Skip if the task has been dismissed. - if ( $this->is_task_dismissed( - [ - 'target_post_id' => $post->ID, - 'provider_id' => $this->get_provider_id(), - ] - ) ) { - continue; - } - - $task_id = $this->get_task_id( [ 'target_post_id' => $post->ID ] ); - - // Don't add the task if it was completed. - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_id ) ) { - continue; - } - - $this->task_post_mappings[ $task_id ] = [ - 'task_id' => $task_id, - 'target_post_id' => $post->ID, - 'target_post_type' => $post->post_type, - ]; - } - - return ! empty( $this->task_post_mappings ); - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( ! $this->should_add_task() ) { - return []; - } - - $task_to_inject = []; - foreach ( $this->task_post_mappings as $task_data ) { - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_data['task_id'] ) ) { - continue; - } - - $task_to_inject[] = [ - 'task_id' => $this->get_task_id( [ 'target_post_id' => $task_data['target_post_id'] ] ), - 'provider_id' => $this->get_provider_id(), - 'target_post_id' => $task_data['target_post_id'], - 'target_post_type' => $task_data['target_post_type'], - 'date' => \gmdate( 'oW' ), - 'post_title' => $this->get_title_with_data( $task_data ), - 'url' => $this->get_url_with_data( $task_data ), - 'url_target' => $this->get_url_target(), - 'dismissable' => $this->is_dismissable(), - 'priority' => $this->get_priority(), - 'points' => $this->get_points(), - 'external_link_url' => $this->get_external_link_url(), - ]; - } - - $added_tasks = []; - - foreach ( $task_to_inject as $task_data ) { - // Skip the task if it was already injected. - if ( \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ) ) { - continue; - } - - $added_tasks[] = \progress_planner()->get_suggested_tasks_db()->add( $task_data ); - } - - return $added_tasks; - } - - /** - * This method is added just to override the parent method. - * For this task provider we can't check if it is snoozed like for other as we snooze the task for specific post. - * Check for that is included in the should_add_task method. - * - * @return bool - */ - public function is_task_snoozed() { - return false; - } - - /** - * Get the old posts. - * - * @param array $args The args. - * - * @return \WP_Post[] - */ - public function get_old_posts( $args = [] ) { - $posts = []; - - // Parse default args. - $args = \wp_parse_args( - $args, - [ - 'posts_per_page' => static::ITEMS_TO_INJECT, - 'post_status' => 'publish', - 'orderby' => 'modified', - 'order' => 'ASC', - 'ignore_sticky_posts' => true, - 'date_query' => [ - [ - 'column' => 'post_modified', - 'before' => '-12 months', - ], - ], - ] - ); - - /** - * Filters the args for the posts & pages we want user to review. - * - * @param array $args The get_posts args. - */ - $args = \apply_filters( 'progress_planner_update_posts_tasks_args', $args ); - - // Get the post that was updated last. - $posts = \get_posts( $args ); - - return $posts ? $posts : []; - } - - /** - * Filter the review posts tasks args. - * - * @param array $args The args. - * - * @return array - */ - public function filter_update_posts_args( $args ) { - $args['post__not_in'] = isset( $args['post__not_in'] ) - ? $args['post__not_in'] - : []; - - $args['post__not_in'] = \array_merge( - $args['post__not_in'], - // Add the snoozed post IDs to the post__not_in array. - $this->get_snoozed_post_ids(), - ); - - if ( ! empty( $this->get_dismissed_post_ids() ) ) { - $args['post__not_in'] = \array_merge( $args['post__not_in'], $this->get_dismissed_post_ids() ); - } - - if ( \function_exists( 'YoastSEO' ) ) { - // Handle the case when the meta key doesn't exist. - $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'relation' => 'OR', - [ - 'key' => '_yoast_wpseo_content_score', - 'compare' => 'EXISTS', - ], - [ - 'key' => '_yoast_wpseo_content_score', - 'compare' => 'NOT EXISTS', - ], - ]; - - $args['orderby'] = 'meta_value_num'; - $args['order'] = 'ASC'; - } - - return $args; - } - - /** - * Get the snoozed post IDs. - * - * @return array - */ - protected function get_snoozed_post_ids() { - if ( ! empty( $this->snoozed_post_ids ) ) { - return $this->snoozed_post_ids; - } - - $snoozed = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'future' ] ); - - foreach ( $snoozed as $task ) { - /** - * The task object. - * - * @var \Progress_Planner\Suggested_Tasks\Task $task - */ - if ( isset( $task->provider->slug ) && 'review-post' === $task->provider->slug ) { - $this->snoozed_post_ids[] = $task->target_post_id; - } - } - - return $this->snoozed_post_ids; - } - - /** - * Get the dismissed post IDs. - * - * @return array - */ - protected function get_dismissed_post_ids() { - if ( ! empty( $this->dismissed_post_ids ) ) { - return $this->dismissed_post_ids; - } - - $dismissed = $this->get_dismissed_tasks(); - - if ( ! empty( $dismissed ) ) { - $this->dismissed_post_ids = \array_values( \wp_list_pluck( $dismissed, 'post_id' ) ); - } - - return $this->dismissed_post_ids; - } - - /** - * Get the task identifier for storing dismissal data. - * Override this method in the implementing class to provide task-specific identification. - * - * @param array $task_data The task data. - * - * @return string|false The task identifier or false if not applicable. - */ - protected function get_task_identifier( $task_data ) { - return $this->get_provider_id() . '-' . $task_data['target_post_id']; - } - - /** - * Get the saved page-types. - * - * @return int[] - */ - protected function get_saved_page_types() { - $ids = []; - // Add the saved page-types to the post__not_in array. - $page_types = \progress_planner()->get_admin__page_settings()->get_settings(); - foreach ( $page_types as $page_type ) { - if ( isset( $page_type['value'] ) && 0 !== (int) $page_type['value'] ) { - $ids[] = (int) $page_type['value']; - } - } - return $ids; - } - - /** - * Check if a specific task is completed. - * - * @param string $task_id The task ID to check. - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); - - if ( ! $task ) { - return false; - } - - $data = $task->get_data(); - - return $data && isset( $data['target_post_id'] ) - && (int) \get_post_modified_time( 'U', false, (int) $data['target_post_id'] ) > \strtotime( '-12 months' ); - } - - /** - * Add the Yoast cornerstone pages to the important page IDs. - * - * @param int[] $important_page_ids The important page IDs. - * @return int[] - */ - public function add_yoast_cornerstone_pages( $important_page_ids ) { - if ( ! \function_exists( 'YoastSEO' ) ) { - return $important_page_ids; - } - $cornerstone_page_ids = \get_posts( - [ - 'post_type' => 'any', - 'meta_key' => '_yoast_wpseo_is_cornerstone', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => '1', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value - 'fields' => 'ids', - ] - ); - if ( ! empty( $cornerstone_page_ids ) ) { - $important_page_ids = \array_merge( $important_page_ids, $cornerstone_page_ids ); - } - return $important_page_ids; - } - - /** - * Get the expiration period in seconds. - * - * @param array $dismissal_data The dismissal data. - * - * @return int The expiration period in seconds. - */ - protected function get_expiration_period( $dismissal_data ) { - if ( ! isset( $dismissal_data['post_id'] ) ) { - return 6 * MONTH_IN_SECONDS; - } - - // Important pages have term from 'progress_planner_page_types' taxonomy assigned. - $has_term = \has_term( '', Page_Types::TAXONOMY_NAME, $dismissal_data['post_id'] ); - if ( $has_term ) { - return 6 * MONTH_IN_SECONDS; - } - - // Check if it his cornerstone content. - if ( \function_exists( 'YoastSEO' ) ) { - $is_cornerstone = \get_post_meta( $dismissal_data['post_id'], '_yoast_wpseo_is_cornerstone', true ); - if ( '1' === $is_cornerstone ) { - return 6 * MONTH_IN_SECONDS; - } - } - - return 12 * MONTH_IN_SECONDS; - } - - /** - * 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 = [] ) { - $task_post = \progress_planner()->get_suggested_tasks_db()->get_post( $data['id'] ); - if ( ! $task_post ) { - return $actions; - } - - $task_data = $task_post->get_data(); - - if ( isset( $task_data['target_post_id'] ) ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Review', 'progress-planner' ) . '', - ]; - } - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-core-update.php b/classes/suggested-tasks/providers/class-core-update.php deleted file mode 100644 index 0a681b4373..0000000000 --- a/classes/suggested-tasks/providers/class-core-update.php +++ /dev/null @@ -1,145 +0,0 @@ -is_task_completed() ) { - foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'publish' ] ) as $task ) { - if ( $this->get_task_id() === \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) { - $update_actions['prpl_core_update'] = - 'Progress Planner' . - '' . \esc_html__( 'Click here to celebrate your completed task!', 'progress-planner' ) . ''; - break; - } - } - } - - return $update_actions; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - // Without this \wp_get_update_data() might not return correct data for the core updates (depending on the timing). - if ( ! \function_exists( 'get_core_updates' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/update.php'; - } - - // For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10). - return 0 < \wp_get_update_data()['counts']['total']; - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return \wp_doing_ajax() ? false : ! $this->should_add_task(); - } - - /** - * 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__( 'Go to the Updates page', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-debug-display.php b/classes/suggested-tasks/providers/class-debug-display.php deleted file mode 100644 index bdbfd23224..0000000000 --- a/classes/suggested-tasks/providers/class-debug-display.php +++ /dev/null @@ -1,60 +0,0 @@ - 'options-discussion.php', - 'iconEl' => 'label[for="page_comments"]', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Disable comment pagination', 'progress-planner' ); - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - return $this->are_dependencies_satisfied() && \get_option( 'page_comments' ); - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return ! \get_option( 'page_comments' ); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \printf( - /* translators: %d is the number of comments per page, %s is the "recommend to disable comment pagination" link */ - \esc_html__( 'When comment pagination is enabled, your site creates a new page for every %1$d comments. This is not helping your website in search engines, and can break up the ongoing conversation. That\'s why we %2$s.', 'progress-planner' ), - (int) \get_option( 'comments_per_page' ), - '' . \esc_html__( 'recommend to disable comment pagination', 'progress-planner' ) . '' - ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable comment pagination', 'progress-planner' ) ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable pagination', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-disable-comments.php b/classes/suggested-tasks/providers/class-disable-comments.php deleted file mode 100644 index 6631fc3c80..0000000000 --- a/classes/suggested-tasks/providers/class-disable-comments.php +++ /dev/null @@ -1,179 +0,0 @@ - 'options-discussion.php', - 'iconEl' => 'label[for="default_comment_status"]', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Disable comments', 'progress-planner' ); - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - return ! \progress_planner()->get_plugin_installer()->is_plugin_activated( 'comment-free-zone' ) - && 10 > \wp_count_comments()->approved - && 'open' === \get_default_comment_status(); - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return 'open' !== \get_default_comment_status(); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - $comments_count = (int) \wp_count_comments()->approved; - - echo '

    '; - if ( 0 === $comments_count ) { - \esc_html_e( 'Your site currently has no approved comments. Therefore, it seems your site might not need comments. If that is true for most posts or pages on your site, you can use WordPress\'s default setting to disable comments.', 'progress-planner' ); - } else { - \printf( - \esc_html( - // translators: %d is the number of approved comments. - \_n( - 'Your site currently has %d approved comment. Therefore, it seems your site might not need comments. If that is true for most posts or pages on your site, you can use WordPress\'s default setting to disable comments.', - 'Your site currently has %d approved comments. Therefore, it seems your site might not need comments. If that is true for most posts or pages on your site, you can use WordPress\'s default setting to disable comments.', - $comments_count, - 'progress-planner' - ) - ), - (int) $comments_count - ); - } - echo '

    '; - if ( ! \is_multisite() && \current_user_can( 'install_plugins' ) ) { - echo '

    '; - \printf( - /* translators: %s is the Comment-Free Zone link */ - \esc_html__( 'If your site really doesn\'t need any comments, we recommend installing the "%s" plugin.', 'progress-planner' ), - '' . \esc_html__( 'Comment-Free Zone', 'progress-planner' ) . '' - ); - echo '

    '; - } - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - ?> -
    - -
    - - - - 10, - 'html' => '' . \esc_html__( 'Disable comments', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-email-sending.php b/classes/suggested-tasks/providers/class-email-sending.php deleted file mode 100644 index 3225fff5a3..0000000000 --- a/classes/suggested-tasks/providers/class-email-sending.php +++ /dev/null @@ -1,356 +0,0 @@ -email_subject = \esc_html__( 'Your Progress Planner test message!', 'progress-planner' ); - - // Generate a secure token for the completion link to prevent CSRF. - $user_id = \get_current_user_id(); - $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $this->get_task_id(), $user_id ); - - $this->email_content = \sprintf( - // translators: %1$s the admin URL. - \__( 'You just used Progress Planner to verify if sending email works on your website.

    The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), - \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() . '&token=' . $token ), - \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) - ); - } - - /** - * Get the troubleshooting guide URL. - * - * @return string - */ - protected function get_troubleshooting_guide_url() { - return \esc_url( \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/troubleshoot-smtp' ) ); - } - - /** - * We want task to be added always. - * - * @return bool - */ - public function should_add_task() { - return true; - } - - /** - * Task should be completed only manually by the user. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return false; - } - - /** - * Task should be completed only manually by the user. - * - * @param string $task_id The task ID. - * - * @return bool|string - */ - public function evaluate_task( $task_id ) { - return false; - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Test if your website can send emails correctly', 'progress-planner' ); - } - - /** - * Get the description. - * - * @param array $task_data Optional data to include in the task. - * @return string - */ - protected function get_description( $task_data = [] ) { - return \esc_html__( 'Your website tries to send you important email. Test if sending email from your site works well.', 'progress-planner' ); - } - - /** - * Enqueue the scripts. - * - * @param string $hook The current admin page. - * - * @return void - */ - public function enqueue_scripts( $hook ) { - // Enqueue the script only on Progress Planner and WP dashboard pages. - if ( 'toplevel_page_progress-planner' !== $hook && 'index.php' !== $hook ) { - return; - } - - // Don't enqueue the script if the task is already completed. - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $this->get_task_id() ) ) { - return; - } - - // Enqueue the web component. - \progress_planner()->get_admin__enqueue()->enqueue_script( - 'progress-planner/web-components/prpl-task-' . $this->get_provider_id(), - [ - 'name' => 'prplEmailSending', - 'data' => [ - 'ajax_url' => \admin_url( 'admin-ajax.php' ), - 'nonce' => \wp_create_nonce( 'progress_planner' ), - 'unknown_error' => \esc_html__( 'Unknown error', 'progress-planner' ), - 'troubleshooting_guide_url' => $this->get_troubleshooting_guide_url(), - ], - ] - ); - } - - /** - * Check if wp_mail is filtered. - * - * @return void - */ - public function check_if_wp_mail_is_filtered() { - global $wp_filter; - foreach ( [ 'phpmailer_init', 'pre_wp_mail' ] as $filter ) { - $has_filter = isset( $wp_filter[ $filter ] ) && ! empty( $wp_filter[ $filter ]->callbacks ) ? true : false; // @phpstan-ignore-line property.nonObject - $this->is_wp_mail_filtered = $this->is_wp_mail_filtered || $has_filter; - } - } - - /** - * Check if wp_mail has an override. - * - * @return void - */ - public function check_if_wp_mail_has_override() { - // Just in case, since it will trigger PHP fatal error if the function doesn't exist. - if ( \function_exists( 'wp_mail' ) ) { - $file_path = ( new \ReflectionFunction( 'wp_mail' ) )->getFileName(); - - $this->is_wp_mail_overridden = $file_path && $file_path !== ABSPATH . 'wp-includes/pluggable.php'; - } - } - - /** - * Whether there is an email override. - * - * @return bool - */ - protected function is_there_sending_email_override() { - return $this->is_wp_mail_filtered || $this->is_wp_mail_overridden; - } - - /** - * Test email sending. - * - * Use check_ajax_referer and get email from $_POST. - * - * @return void - */ - public function ajax_test_email_sending() { - - if ( ! $this->capability_required() ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to test email sending.', 'progress-planner' ) ] ); - } - - // Use check_ajax_referer for AJAX handlers. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - // Get email from POST data (AJAX request). - $email_address = isset( $_POST['email_address'] ) ? \sanitize_email( \wp_unslash( $_POST['email_address'] ) ) : ''; - - if ( ! $email_address ) { - \wp_send_json_error( \esc_html__( 'Invalid email address.', 'progress-planner' ) ); - } - - // Regenerate email content with fresh token for current user. - $user_id = \get_current_user_id(); - $token = \progress_planner()->get_suggested_tasks()->generate_task_completion_token( $this->get_task_id(), $user_id ); - - $email_content = \sprintf( - // translators: %1$s the admin URL. - \__( 'You just used Progress Planner to verify if sending email works on your website.

    The good news; it does! Click here to mark %2$s\'s Recommendation as completed.', 'progress-planner' ), - \admin_url( 'admin.php?page=progress-planner&prpl_complete_task=' . $this->get_task_id() . '&token=' . $token ), - \esc_html( \progress_planner()->get_ui__branding()->get_ravi_name() ) - ); - - $headers = [ 'Content-Type: text/html; charset=UTF-8' ]; - - $result = \wp_mail( $email_address, $this->email_subject, $email_content, $headers ); - - if ( $result ) { - \wp_send_json_success( \esc_html__( 'Email sent successfully.', 'progress-planner' ) ); - } - \wp_send_json_error( $this->email_error ); - } - - /** - * Set the email error. - * - * @param \WP_Error $e The error message. - * - * @return void - */ - public function set_email_error( $e ) { - $this->email_error = $e->get_error_message() ? $e->get_error_message() : \esc_html__( 'Unknown error', 'progress-planner' ); - } - - /** - * The popover content. - * - * @return void - */ - public function the_popover_content() { - \progress_planner()->the_view( - 'popovers/email-sending.php', - [ - 'prpl_popover_id' => static::POPOVER_ID, - 'prpl_external_link_url' => $this->get_external_link_url(), - 'prpl_provider_id' => $this->get_provider_id(), - 'prpl_email_subject' => $this->email_subject, - 'prpl_email_error' => $this->email_error, - 'prpl_troubleshooting_guide_url' => $this->get_troubleshooting_guide_url(), - 'prpl_is_there_sending_email_override' => $this->is_there_sending_email_override(), - 'prpl_task_actions' => $this->get_task_actions(), - ] - ); - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - // The form is handled in the popovers/email-sending view. - } - - /** - * 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__( 'Test email sending', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-fewer-tags.php b/classes/suggested-tasks/providers/class-fewer-tags.php deleted file mode 100644 index 21a929474c..0000000000 --- a/classes/suggested-tasks/providers/class-fewer-tags.php +++ /dev/null @@ -1,217 +0,0 @@ -post_tag_count_data_collector = new Post_Tag_Count(); - $this->published_post_count_data_collector = new Published_Post_Count(); - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return \admin_url( '/plugin-install.php?tab=search&s=fewer+tags' ); - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Install Fewer Tags and clean up your tags', 'progress-planner' ); - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - // If the plugin is active, we don't need to add the task. - return $this->is_plugin_active() ? false : $this->is_task_relevant(); - } - - /** - * Check if the task is relevant. - * - * @return bool - */ - public function is_task_relevant() { - return $this->post_tag_count_data_collector->collect() > $this->published_post_count_data_collector->collect(); - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return $this->is_plugin_active(); - } - - /** - * Check if the plugin is active. - * - * @return bool - */ - protected function is_plugin_active() { - if ( null === $this->is_plugin_active ) { - if ( ! \function_exists( 'get_plugins' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - - $plugins = \get_plugins(); - $this->is_plugin_active = isset( $plugins[ $this->plugin_path ] ) && \is_plugin_active( $this->plugin_path ); - } - - return $this->is_plugin_active; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \printf( - // translators: %1$s is the number of tags, %2$s is the number of published posts. - \esc_html__( 'Your site has %1$s tags across %2$s published posts. Having too many tags can dilute your content organization and hurt SEO. The "Fewer Tags" plugin helps you consolidate similar tags.', 'progress-planner' ), - (int) $this->post_tag_count_data_collector->collect(), - (int) $this->published_post_count_data_collector->collect(), - ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - if ( ! \is_multisite() && \current_user_can( 'install_plugins' ) ) : ?> - - 10, - 'html' => '' . \esc_html__( 'Install plugin', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-hello-world.php b/classes/suggested-tasks/providers/class-hello-world.php deleted file mode 100644 index fe431f4199..0000000000 --- a/classes/suggested-tasks/providers/class-hello-world.php +++ /dev/null @@ -1,175 +0,0 @@ -get_data_collector()->collect(); - - if ( 0 === $hello_world_post_id ) { - return ''; - } - // We don't use the edit_post_link() function because we need to bypass it's current_user_can() check. - $this->url = \esc_url( - \add_query_arg( - [ - 'post' => $hello_world_post_id, - 'action' => 'edit', - ], - \admin_url( 'post.php' ) - ) - ); - - return $this->url; - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Delete the "Hello World!" post.', 'progress-planner' ); - } - - /** - * Get the description. - * - * @return string - */ - protected function get_description() { - $hello_world_post_id = $this->get_data_collector()->collect(); - - if ( 0 === $hello_world_post_id ) { - return \esc_html__( 'On install, WordPress creates a "Hello World!" post. This post is not needed and should be deleted.', 'progress-planner' ); - } - - $hello_world_post_url = (string) \get_permalink( $hello_world_post_id ); - - $content = '

    '; - $content .= \sprintf( - /* translators: %s: Link to the post. */ - \esc_html__( 'On install, WordPress creates a "Hello World!" post. You can find yours at %s.', 'progress-planner' ), - '' . \esc_html( $hello_world_post_url ) . '', - ); - $content .= '

    '; - $content .= \esc_html__( 'This post does not add value to your website and solely exists to show what a post can look like. Therefore, "Hello World!" is not needed and should be deleted.', 'progress-planner' ); - $content .= '

    '; - - return $content; - } - - /** - * Check if the task condition is satisfied. - * - * @return bool - */ - public function should_add_task() { - return 0 !== $this->get_data_collector()->collect(); - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Delete the "Hello World!" post', 'progress-planner' ) ); - } - - /** - * Get the enqueue data. - * - * @return array - */ - protected function get_enqueue_data() { - return [ - 'name' => 'helloWorldData', - 'data' => [ - 'postId' => $this->get_data_collector()->collect(), - ], - ]; - } - - /** - * 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__( 'Delete', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-improve-pdf-handling.php b/classes/suggested-tasks/providers/class-improve-pdf-handling.php deleted file mode 100644 index ed036463bb..0000000000 --- a/classes/suggested-tasks/providers/class-improve-pdf-handling.php +++ /dev/null @@ -1,211 +0,0 @@ -get_utils__cache()->get( 'pdf_files_count' ); - if ( false === $pdf_files_count ) { - // Detect if there are more than 10 PDF files. - $query = new \WP_Query( - [ - 'post_type' => 'attachment', - 'post_mime_type' => 'application/pdf', - 'post_status' => 'inherit', - 'posts_per_page' => static::MIN_PDF_FILES + 1, // We want to get at least 11 PDF files to be sure we have enough. - 'fields' => 'ids', - ] - ); - $pdf_files_count = $query->found_posts; - \progress_planner()->get_utils__cache()->set( 'pdf_files_count', $pdf_files_count, DAY_IN_SECONDS ); - } - - return static::MIN_PDF_FILES < (int) $pdf_files_count; - } - - /** - * Task should be completed only manually by the user. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return false; - } - - /** - * Task should be completed only manually by the user. - * - * @param string $task_id The task ID. - * - * @return bool|string - */ - public function evaluate_task( $task_id ) { - return false; - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Improve PDF handling', 'progress-planner' ); - } - - /** - * Get the description. - * - * @param array $task_data Optional data to include in the task. - * @return string - */ - protected function get_description( $task_data = [] ) { - return \esc_html__( 'Your site seems to have quite a few PDF files, we can improve the way your site handles them.', 'progress-planner' ); - } - - /** - * Enqueue the scripts. - * - * @param string $hook The current admin page. - * - * @return void - */ - public function enqueue_scripts( $hook ) { - // Enqueue the script only on Progress Planner and WP dashboard pages. - if ( 'toplevel_page_progress-planner' !== $hook && 'index.php' !== $hook ) { - return; - } - - // Don't enqueue the script if the task is already completed. - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $this->get_task_id() ) ) { - return; - } - - // Enqueue the web component. - \progress_planner()->get_admin__enqueue()->enqueue_script( - 'progress-planner/web-components/prpl-task-' . $this->get_provider_id(), - ); - } - - /** - * The popover content. - * - * @return void - */ - public function the_popover_content() { - \progress_planner()->the_view( - 'popovers/improve-pdf-handling.php', - [ - 'prpl_popover_id' => static::POPOVER_ID, - 'prpl_provider_id' => $this->get_provider_id(), - ] - ); - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - // The form is handled in the popovers/email-sending view. - } - - /** - * 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__( 'Improve PDF handling', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-permalink-structure.php b/classes/suggested-tasks/providers/class-permalink-structure.php deleted file mode 100644 index b2a4f292dd..0000000000 --- a/classes/suggested-tasks/providers/class-permalink-structure.php +++ /dev/null @@ -1,301 +0,0 @@ -is_task_completed() ) { - $permalink_structure = \get_option( 'permalink_structure' ); - - if ( '/%year%/%monthnum%/%postname%/' === $permalink_structure || '/index.php/%year%/%monthnum%/%postname%/' === $permalink_structure ) { - $icon_el = 'label[for="permalink-input-month-name"]'; - } - - if ( '/%postname%/' === $permalink_structure || '/index.php/%postname%/' === $permalink_structure ) { - $icon_el = 'label[for="permalink-input-post-name"]'; - } - } - - return [ - 'hook' => 'options-permalink.php', - 'iconEl' => $icon_el, - ]; - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Set permalink structure', 'progress-planner' ); - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - $permalink_structure = \get_option( 'permalink_structure' ); - return '/%year%/%monthnum%/%day%/%postname%/' === $permalink_structure || '/index.php/%year%/%monthnum%/%day%/%postname%/' === $permalink_structure; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'By default, WordPress uses date-based URLs (e.g., /2025/01/21/post-name/) which can make your content seem outdated. SEO-friendly URLs help search engines and visitors better understand your content.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $permalink_structure = \get_option( 'permalink_structure' ); - $prefix = \got_url_rewrite() ? '' : '/index.php'; - $url_base = \home_url( $prefix ); - $index_php_prefix = \got_url_rewrite() ? '' : '/index.php'; - - // Default structure values from WP core. - $structures = [ - [ - 'id' => 'day-name', - 'value' => $index_php_prefix . '/%year%/%monthnum%/%day%/%postname%/', - 'label' => \__( 'Day and name', 'progress-planner' ), - 'code' => $url_base . '/' . \gmdate( 'Y/m/d' ) . '/sample-post/', - ], - [ - 'id' => 'month-name', - 'value' => $index_php_prefix . '/%year%/%monthnum%/%postname%/', - 'label' => \__( 'Month and name', 'progress-planner' ), - 'code' => $url_base . '/' . \gmdate( 'Y/m' ) . '/sample-post/', - ], - [ - 'id' => 'numeric', - 'value' => $index_php_prefix . '/archives/%post_id%', - 'label' => \__( 'Numeric', 'progress-planner' ), - 'code' => $url_base . '/archives/123', - ], - [ - 'id' => 'post-name', - 'value' => $index_php_prefix . '/%postname%/', - 'label' => \__( 'Post name', 'progress-planner' ), - 'code' => $url_base . '/sample-post/', - ], - ]; - - $default_structure_values = \wp_list_pluck( $structures, 'value' ); - $is_custom = ! \in_array( $permalink_structure, $default_structure_values, true ); - ?> -
    -
    - -
    - -
    - - - -
    - -
    - -
    - - -
    -
    -
    - print_submit_button( \__( 'Set permalink structure', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change core permalink settings. - * The $_POST data is expected to be: - * - value: (mixed) The value to update the setting to. - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['value'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); - } - - $permalink_structure = \trim( \sanitize_text_field( \wp_unslash( $_POST['value'] ) ) ); - - if ( empty( $permalink_structure ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid permalink structure.', 'progress-planner' ) ] ); - } - - // Update the permalink structure. - \update_option( 'permalink_structure', $permalink_structure ); - - // Set a transient to flush rewrite rules on the next page load. - // This is more performant than flushing immediately during the AJAX request. - \set_transient( 'prpl_flush_rewrite_rules', 1, HOUR_IN_SECONDS ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Permalink structure updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Select permalink structure', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-php-version.php b/classes/suggested-tasks/providers/class-php-version.php deleted file mode 100644 index 798634ba9c..0000000000 --- a/classes/suggested-tasks/providers/class-php-version.php +++ /dev/null @@ -1,67 +0,0 @@ -url = \admin_url( '/plugin-install.php?tab=search&s=aaa+option+optimizer' ); - - /** - * Filter the autoloaded options threshold. - * - * @param int $threshold The threshold. - * - * @return int - */ - $this->autoloaded_options_threshold = (int) \apply_filters( 'progress_planner_reduce_autoloaded_options_threshold', $this->autoloaded_options_threshold ); - } - - /** - * Get the title. - * - * @return string - */ - public function get_title() { - return \esc_html__( 'Reduce number of autoloaded options', 'progress-planner' ); - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - // If the plugin is active, we don't need to add the task. - if ( $this->is_plugin_active() ) { - return false; - } - - return $this->get_autoloaded_options_count() > $this->autoloaded_options_threshold; - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return $this->is_plugin_active() || $this->get_autoloaded_options_count() <= $this->autoloaded_options_threshold; - } - - /** - * Check if the plugin is active. - * - * @return bool - */ - protected function is_plugin_active() { - - if ( null === $this->is_plugin_active ) { - if ( ! \function_exists( 'get_plugins' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - - $plugins = get_plugins(); - $this->is_plugin_active = isset( $plugins[ $this->plugin_path ] ) && is_plugin_active( $this->plugin_path ); - } - - return $this->is_plugin_active; - } - - /** - * Get the number of autoloaded options. - * - * @return int - */ - protected function get_autoloaded_options_count() { - global $wpdb; - - if ( null === $this->autoloaded_options_count ) { - $autoload_values = \wp_autoload_values_to_autoload(); - $placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) ); - - // phpcs:disable WordPress.DB - $this->autoloaded_options_count = $wpdb->get_var( - $wpdb->prepare( "SELECT COUNT(*) FROM `{$wpdb->options}` WHERE autoload IN ( $placeholders )", $autoload_values ) // @phpstan-ignore-line property.nonObject - ); - - } - - return $this->autoloaded_options_count; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \printf( - // translators: %d is the number of autoloaded options. - \esc_html__( 'There are %d autoloaded options. If you don\'t need them, consider reducing them by installing the "AAA Option Optimizer" plugin.', 'progress-planner' ), - (int) $this->get_autoloaded_options_count(), - ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - ?> - - - - 10, - 'html' => '' . \esc_html__( 'Reduce', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-remove-inactive-plugins.php b/classes/suggested-tasks/providers/class-remove-inactive-plugins.php deleted file mode 100644 index 44dc218a30..0000000000 --- a/classes/suggested-tasks/providers/class-remove-inactive-plugins.php +++ /dev/null @@ -1,95 +0,0 @@ -get_data_collector()->collect() > 0; - } - - /** - * 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__( 'Go to the "Plugins" page', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php b/classes/suggested-tasks/providers/class-remove-terms-without-posts.php deleted file mode 100644 index 79c8cf9f2a..0000000000 --- a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php +++ /dev/null @@ -1,465 +0,0 @@ -public ) { - return; - } - - foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ) as $task ) { - /** - * The task post object. - * - * @var \Progress_Planner\Suggested_Tasks\Task $task - */ - if ( $task->target_term_id && $task->target_taxonomy ) { - $term = \get_term( $task->target_term_id, $task->target_taxonomy ); - - // If the term is NULL it means the term was deleted, but we want to keep the task (and award a point). - if ( ! $term ) { - continue; - } - - // If the taxonomy is not found the $term will be a WP_Error object. - if ( \is_wp_error( $term ) || $term->count > self::MIN_POSTS ) { - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - } - } - } - } - - /** - * Get the title. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_title_with_data( $task_data = [] ) { - - if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { - return ''; - } - - $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); - return ( $term && ! \is_wp_error( $term ) ) - ? \sprintf( - /* translators: %s: The term name */ - \esc_html__( 'Remove term named "%s"', 'progress-planner' ), - \esc_html( $term->name ) - ) - : ''; - } - - /** - * Get the URL. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_url_with_data( $task_data = [] ) { - - if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { - return ''; - } - - $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); - return ( $term && ! \is_wp_error( $term ) ) - ? \admin_url( 'term.php?taxonomy=' . $term->taxonomy . '&tag_ID=' . $term->term_id ) - : ''; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return ! empty( $this->get_data_collector()->collect() ); - } - - /** - * Check if a specific task is completed. - * Child classes can override this method to handle specific task IDs. - * - * @param string $task_id The task ID to check. - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - $term = $this->get_term_from_task_id( $task_id ); - return $term ? self::MIN_POSTS < $term->count : true; - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( - true === $this->is_task_snoozed() || - ! $this->should_add_task() // No need to add the task. - ) { - return []; - } - - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - $task_id = $this->get_task_id( - [ - 'target_term_id' => $data['target_term_id'], - 'target_taxonomy' => $data['target_taxonomy'], - ] - ); - - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_id ) ) { - return []; - } - - // Transform the data to match the task data structure. - $task_data = $this->modify_injection_task_data( - $this->get_task_details( - $data - ) - ); - - // Get the task post. - $task_post = \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ); - - // Skip the task if it was already injected. - return $task_post ? [] : [ \progress_planner()->get_suggested_tasks_db()->add( $task_data ) ]; - } - - /** - * Modify task data before injecting it. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_injection_task_data( $task_data ) { - // Transform the data to match the task data structure. - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - - $task_data['target_term_id'] = $data['target_term_id']; - $task_data['target_taxonomy'] = $data['target_taxonomy']; - $task_data['target_term_name'] = $data['target_term_name']; - - return $task_data; - } - - /** - * Get the term from the task ID. - * - * @param string $task_id The task ID. - * - * @return \WP_Term|null - */ - public function get_term_from_task_id( $task_id ) { - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); - - if ( empty( $tasks ) ) { - return null; - } - - $task = $tasks[0]; - - if ( ! $task->target_term_id || ! $task->target_taxonomy ) { - return null; - } - - $term = \get_term( $task->target_term_id, $task->target_taxonomy ); - return $term && ! \is_wp_error( $term ) ? $term : null; - } - - /** - * Get the dismissed term IDs. - * - * @return array - */ - protected function get_completed_term_ids() { - if ( null !== $this->completed_term_ids ) { - return $this->completed_term_ids; - } - - $this->completed_term_ids = []; - - foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ) as $task ) { - if ( 'trash' === $task->post_status ) { - $this->completed_term_ids[] = $task->target_term_id; - } - } - - return $this->completed_term_ids; - } - - /** - * Exclude completed terms. - * - * @param array $exclude_term_ids The excluded term IDs. - * @return array - */ - public function exclude_completed_terms( $exclude_term_ids ) { - return \array_merge( $exclude_term_ids, $this->get_completed_term_ids() ); - } - - /** - * 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 = [] ) { - - if ( ! isset( $data['slug'] ) ) { - return $actions; - } - - $term = $this->get_term_from_task_id( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $data['slug'] ) ); - - // If the term is not found, return the actions. - if ( ! $term ) { - return $actions; - } - - $task_data = [ - 'target_term_id' => $term->term_id, - 'target_taxonomy' => $term->taxonomy, - 'target_term_name' => $term->name, - ]; - - $task_details = $this->get_task_details( $task_data ); - - $taxonomy = \get_taxonomy( $term->taxonomy ); - - $actions[] = [ - 'priority' => 10, - 'html' => \sprintf( - ' - %s - ', - \htmlspecialchars( - \wp_json_encode( - [ - 'post_title' => $task_details['post_title'], - 'target_term_id' => $task_data['target_term_id'], - 'target_taxonomy' => $task_data['target_taxonomy'], - 'target_taxonomy_name' => $taxonomy ? $taxonomy->label : '', - 'target_term_name' => $task_data['target_term_name'], - ] - ), - ENT_QUOTES, - 'UTF-8' - ), - \esc_attr( static::POPOVER_ID ), - \esc_html__( 'Delete term', 'progress-planner' ) - ), - ]; - - return $actions; - } - - /** - * Print the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'This term has no posts assigned to it. Removing unused terms keeps your site organized, improves navigation, and prevents empty archive pages. Note: This action cannot be undone.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - ?> -
    -

    - - -

    -

    - ', - '' - ); - ?> -

    -
    - - -
    - -
    - \esc_html__( 'You do not have permission to delete terms.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['term_id'] ) || ! isset( $_POST['taxonomy'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing term information.', 'progress-planner' ) ] ); - } - - $term_id = \absint( \wp_unslash( $_POST['term_id'] ) ); - $taxonomy = \sanitize_text_field( \wp_unslash( $_POST['taxonomy'] ) ); - - // Verify the term exists. - $term = \get_term( $term_id, $taxonomy ); - if ( ! $term || \is_wp_error( $term ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Term not found.', 'progress-planner' ) ] ); - } - - // Delete the term. - $result = \wp_delete_term( $term_id, $taxonomy ); - - if ( \is_wp_error( $result ) ) { - \wp_send_json_error( [ 'message' => $result->get_error_message() ] ); - } - - \wp_send_json_success( [ 'message' => \esc_html__( 'Term deleted successfully.', 'progress-planner' ) ] ); - } -} diff --git a/classes/suggested-tasks/providers/class-rename-uncategorized-category.php b/classes/suggested-tasks/providers/class-rename-uncategorized-category.php deleted file mode 100644 index 5f54763b47..0000000000 --- a/classes/suggested-tasks/providers/class-rename-uncategorized-category.php +++ /dev/null @@ -1,230 +0,0 @@ -get_data_collector()->collect() ); - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Rename Uncategorized category', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return 0 !== $this->get_data_collector()->collect(); - } - - /** - * Update the Uncategorized category cache. - * - * @return void - */ - public function update_uncategorized_category_cache() { - $this->get_data_collector()->update_uncategorized_category_cache(); // @phpstan-ignore-line method.notFound - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress assigns posts to "Uncategorized" by default if no category is selected. Renaming this to something meaningful (like "General" or your main topic) creates a better user experience and looks more professional.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $uncategorized_category_id = $this->get_data_collector()->collect(); - $uncategorized_category = \get_term( $uncategorized_category_id, 'category' ); - - if ( ! $uncategorized_category || \is_wp_error( $uncategorized_category ) ) { - return; - } - ?> - - - - \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['uncategorized_category_name'] ) || ! isset( $_POST['uncategorized_category_slug'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing uncategorized category name or slug.', 'progress-planner' ) ] ); - } - - $uncategorized_category_name = \trim( \sanitize_text_field( \wp_unslash( $_POST['uncategorized_category_name'] ) ) ); - $uncategorized_category_slug = \trim( \sanitize_text_field( \wp_unslash( $_POST['uncategorized_category_slug'] ) ) ); - - if ( empty( $uncategorized_category_name ) || empty( $uncategorized_category_slug ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid uncategorized category name or slug.', 'progress-planner' ) ] ); - } - - $default_category_name = \__( 'Uncategorized' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain - $default_category_slug = \sanitize_title( \_x( 'Uncategorized', 'Default category slug' ) ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain - - $strtolower = \function_exists( 'mb_strtolower' ) ? 'mb_strtolower' : 'strtolower'; - if ( $strtolower( $uncategorized_category_name ) === $strtolower( $default_category_name ) || $strtolower( $uncategorized_category_slug ) === $strtolower( $default_category_slug ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You cannot use the default name or slug for the Uncategorized category.', 'progress-planner' ) ] ); - } - - $uncategorized_category_id = $this->get_data_collector()->collect(); - - $term = \get_term( $uncategorized_category_id, 'category' ); - if ( ! $term || \is_wp_error( $term ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Uncategorized category not found.', 'progress-planner' ) ] ); - } - - \wp_update_term( - $term->term_id, - 'category', - [ - 'name' => $uncategorized_category_name, - 'slug' => $uncategorized_category_slug, - ] - ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Uncategorized category updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Rename', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-sample-page.php b/classes/suggested-tasks/providers/class-sample-page.php deleted file mode 100644 index e7ae313cf8..0000000000 --- a/classes/suggested-tasks/providers/class-sample-page.php +++ /dev/null @@ -1,174 +0,0 @@ -get_data_collector()->collect(); - - if ( 0 !== $sample_page_id ) { - // We don't use the edit_post_link() function because we need to bypass it's current_user_can() check. - $this->url = \esc_url( - \add_query_arg( - [ - 'post' => $sample_page_id, - 'action' => 'edit', - ], - \admin_url( 'post.php' ) - ) - ); - } - - return $this->url; - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Delete "Sample Page"', 'progress-planner' ); - } - - /** - * Get the description. - * - * @return string - */ - protected function get_description() { - $sample_page_id = $this->get_data_collector()->collect(); - - if ( 0 === $sample_page_id ) { - return \esc_html__( 'On install, WordPress creates a "Sample Page" page. This page does not add value to your website and solely exists to show what a page can look like. Therefore, "Sample Page" is not needed and should be deleted.', 'progress-planner' ); - } - - $sample_page_url = (string) \get_permalink( $sample_page_id ); - - $content = '

    '; - $content .= \sprintf( - /* translators: %s: Link to the post. */ - \esc_html__( 'On install, WordPress creates a "Sample Page" page. You can find yours at %s.', 'progress-planner' ), - '' . \esc_html( $sample_page_url ) . '', - ); - $content .= '

    '; - $content .= \esc_html__( 'This page does not add value to your website and solely exists to show what a page can look like. Therefore, "Sample Page" is not needed and should be deleted.', 'progress-planner' ); - $content .= '

    '; - - return $content; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return 0 !== $this->get_data_collector()->collect(); - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Delete the "Sample Page" page', 'progress-planner' ) ); - } - - /** - * Get the enqueue data. - * - * @return array - */ - protected function get_enqueue_data() { - return [ - 'name' => 'samplePageData', - 'data' => [ - 'postId' => $this->get_data_collector()->collect(), - ], - ]; - } - - /** - * 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__( 'Delete', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-search-engine-visibility.php b/classes/suggested-tasks/providers/class-search-engine-visibility.php deleted file mode 100644 index 54ec0dd7f5..0000000000 --- a/classes/suggested-tasks/providers/class-search-engine-visibility.php +++ /dev/null @@ -1,162 +0,0 @@ - 'options-reading.php', - 'iconEl' => 'label[for="blog_public"]', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Allow your site to be indexed by search engines', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return 0 === (int) \get_option( 'blog_public' ); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'Your site is currently hidden from search engines like Google and Bing. This setting is useful during development, but prevents people from finding your content through search. If your site is ready to go live, you should enable search engine visibility.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Allow search engines to index your site', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - blog_public: (string) The blog public to update. - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - // We're not checking for the return value of the update_option calls, because it will return false if the value is the same. - \update_option( 'blog_public', '1' ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Allow', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php deleted file mode 100644 index b15251b393..0000000000 --- a/classes/suggested-tasks/providers/class-select-locale.php +++ /dev/null @@ -1,362 +0,0 @@ - 'options-general.php', - 'iconEl' => 'label[for="WPLANG"]', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Select your site locale', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $user_lang = $this->get_browser_locale(); - $wp_lang = \get_locale(); - - return $user_lang && ! \str_starts_with( $wp_lang, $user_lang ); - } - - /** - * Get the browser locale. - * - * @return string - */ - protected function get_browser_locale() { - $lang = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '' ) ); - if ( ! $lang ) { - return ''; - } - - $lang = \strtolower( \substr( $lang, 0, 2 ) ); - $lang = \explode( '-', $lang )[0]; - $lang = \explode( '_', $lang )[0]; - - return $lang; - } - - /** - * Get all locales from the WP API. - * - * Not currently used, but could be useful in the future. - * - * @return array - */ - protected function get_locales() { - $cache_key = 'all_locales'; - $cached = \progress_planner()->get_utils__cache()->get( $cache_key ); - if ( $cached ) { - return $cached; - } - - $response = \wp_remote_get( 'https://api.wordpress.org/translations/core/1.0/' ); - if ( \is_wp_error( $response ) ) { - \progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS ); - return []; - } - $body = \wp_remote_retrieve_body( $response ); - $locales = \json_decode( $body, true ); - if ( ! \is_array( $locales ) || ! isset( $locales['translations'] ) ) { - \progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS ); - return []; - } - - // Get the locales. - $locales = \array_map( - fn( $locale ) => [ - 'code' => $locale['language'], - 'name' => $locale['native_name'], - ], - $locales['translations'] - ); - - \progress_planner()->get_utils__cache()->set( $cache_key, $locales, MONTH_IN_SECONDS ); - - // Return the locales. - return $locales; - } - - /** - * Check if the task is completed. - * - * @param string $task_id Optional task ID to check completion for. - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - $activity = \progress_planner()->get_activities__query()->query_activities( - [ - 'category' => 'suggested_task', - 'data_id' => static::PROVIDER_ID, - ] - ); - - return ! empty( $activity ); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    ' . \esc_html__( 'Select your site locale to ensure your site is displayed correctly in the correct language', 'progress-planner' ) . '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - - if ( ! \function_exists( 'wp_get_available_translations' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; - } - - $languages = \get_available_languages(); - $translations = \wp_get_available_translations(); - $locale = \get_locale(); - if ( ! \in_array( $locale, $languages, true ) ) { - $locale = ''; - } - - \wp_dropdown_languages( - [ - 'name' => 'language', - 'id' => 'language', - 'selected' => $locale, - 'languages' => $languages, - 'translations' => $translations, - 'show_available_translations' => \current_user_can( 'install_languages' ) && \wp_can_install_language_pack(), - ] - ); - - $this->print_submit_button( \__( 'Select locale', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - setting: (string) The setting to update. - * - value: (mixed) The value to update the setting to. - * - setting_path: (array) The path to the setting to update. - * Use an empty array if the setting is not nested. - * If the value is nested, use an array of keys. - * Example: [ 'a', 'b', 'c' ] will update the value of $option['a']['b']['c']. - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['value'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting_path'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting path.', 'progress-planner' ) ] ); - } - - $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' ) ] ); - } - - $option_updated = $this->update_language( $language_for_update ); - - if ( $option_updated ) { - \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 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 deleted file mode 100644 index 14807d3bfe..0000000000 --- a/classes/suggested-tasks/providers/class-select-timezone.php +++ /dev/null @@ -1,291 +0,0 @@ -get_task_id() ); - } - - /** - * Get the link setting. - * - * @return array - */ - public function get_link_setting() { - return [ - 'hook' => 'options-general.php', - 'iconEl' => 'label[for="timezone_string"]', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Set site timezone', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $activity = \progress_planner()->get_activities__query()->query_activities( - [ - 'category' => 'suggested_task', - 'data_id' => static::PROVIDER_ID, - ] - ); - - return ! $activity; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'Setting the time zone correctly on your site is valuable. By setting the correct time zone, you ensure scheduled tasks happen exactly when you want them to happen. To correctly account for daylight savings\', we recommend you use the city-based time zone instead of the UTC offset (e.g. Amsterdam or London).', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $current_offset = \get_option( 'gmt_offset' ); - $tzstring = \get_option( 'timezone_string' ); - $was_tzstring_saved = '' !== $tzstring || '0' !== $current_offset ? 'true' : 'false'; - - // Remove old Etc mappings. Fallback to gmt_offset. - if ( \str_contains( $tzstring, 'Etc/GMT' ) ) { - $tzstring = ''; - } - - if ( empty( $tzstring ) ) { // Create a UTC+- zone if no timezone string exists. - if ( 0 === (int) $current_offset ) { - $tzstring = 'UTC+0'; - } elseif ( $current_offset < 0 ) { - $tzstring = 'UTC' . $current_offset; - } else { - $tzstring = 'UTC+' . $current_offset; - } - } - ?> - - print_submit_button( \__( 'Set site timezone', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - setting: (string) The setting to update. - * - value: (mixed) The value to update the setting to. - * - setting_path: (array) The path to the setting to update. - * Use an empty array if the setting is not nested. - * If the value is nested, use an array of keys. - * Example: [ 'a', 'b', 'c' ] will update the value of $option['a']['b']['c']. - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['value'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting_path'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting path.', 'progress-planner' ) ] ); - } - - $timezone_string = \sanitize_text_field( \wp_unslash( $_POST['value'] ) ); - - if ( empty( $timezone_string ) ) { - \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. - if ( \preg_match( '/^UTC[+-]/', $timezone_string ) ) { - // Set the gmt_offset to the value of the timezone_string, strip the UTC prefix. - $gmt_offset = \preg_replace( '/UTC\+?/', '', $timezone_string ); - - // Reset the timezone_string to empty. - $timezone_string = ''; - - $update_options = true; - } elseif ( \in_array( $timezone_string, \timezone_identifiers_list( \DateTimeZone::ALL_WITH_BC ), true ) ) { - // $timezone_string is already set, reset the value for $gmt_offset. - $gmt_offset = ''; - - $update_options = true; - } - - if ( $update_options ) { - \update_option( 'timezone_string', $timezone_string ); - \update_option( 'gmt_offset', $gmt_offset ); - - return true; - } - - return false; - } -} diff --git a/classes/suggested-tasks/providers/class-seo-plugin.php b/classes/suggested-tasks/providers/class-seo-plugin.php deleted file mode 100644 index d170a247fc..0000000000 --- a/classes/suggested-tasks/providers/class-seo-plugin.php +++ /dev/null @@ -1,206 +0,0 @@ -get_data_collector()->collect(); - } - - /** - * Check if the task is completed. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - // Task is completed if an SEO plugin is detected. - return $this->get_data_collector()->collect(); - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - $seo_plugin_recommendation_slug = \progress_planner()->get_ui__branding()->get_seo_plugin_recommendation_slug(); - $seo_plugin_recommendation_name = $this->get_plugin_name_by_slug( $seo_plugin_recommendation_slug ); - if ( ! $seo_plugin_recommendation_name ) { - return; - } - ?> -

    - -

    -

    - %2$s', - \esc_attr( $seo_plugin_recommendation_slug ), - \esc_html( $seo_plugin_recommendation_name ) - ) - ); - ?> -

    - get_ui__branding()->get_seo_plugin_recommendation_slug(); - $seo_plugin_recommendation_name = $this->get_plugin_name_by_slug( $seo_plugin_recommendation_slug ); - if ( ! $seo_plugin_recommendation_name ) { - return; - } - - ?> - - - -

    - -

    -

    - - - -

    - - get_data_collector(); - if ( ! $data_collector instanceof SEO_Plugin_Data_Collector ) { - return null; - } - - $seo_plugins = $data_collector->get_seo_plugins(); - if ( ! isset( $seo_plugins[ $slug ]['name'] ) ) { - return null; - } - - return $seo_plugins[ $slug ]['name']; - } - - /** - * 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__( 'Install plugin', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-set-date-format.php b/classes/suggested-tasks/providers/class-set-date-format.php deleted file mode 100644 index da3e42e932..0000000000 --- a/classes/suggested-tasks/providers/class-set-date-format.php +++ /dev/null @@ -1,334 +0,0 @@ -get_task_id() ); - } - - /** - * Get the link setting. - * - * @return array - */ - public function get_link_setting() { - return [ - 'hook' => 'options-general.php', - 'iconEl' => 'tr:has(input[name="date_format"]) th', - ]; - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return 'wp_default' === $this->get_date_format_type() ? \esc_html__( 'Set site date format', 'progress-planner' ) : \esc_html__( 'Verify site date format', 'progress-planner' ); - } - - /** - * Get the task description. - * - * @return string - */ - protected function get_description() { - return \esc_html__( 'Setting the date format correctly on your site is valuable. By setting the correct date format, you ensure the dates are displayed correctly in the admin area and the front end.', 'progress-planner' ); - } - - /** - * Get the task-action text. - * - * @return string - */ - protected function get_task_action_text() { - return \esc_html__( 'Set date format', 'progress-planner' ); - } - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $date_format_activity = \progress_planner()->get_activities__query()->query_activities( - [ - 'category' => 'suggested_task', - 'data_id' => static::PROVIDER_ID, - ] - ); - - return ! $date_format_activity; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - $detected_date_format = $this->get_date_format_type(); - - if ( ! \function_exists( 'wp_get_available_translations' ) ) { - // @phpstan-ignore-next-line requireOnce.fileNotFound - require_once ABSPATH . 'wp-admin/includes/translation-install.php'; - } - - // Get the site default language name. - $available_languages = \wp_get_available_translations(); - $site_locale = \get_locale(); - - if ( isset( $available_languages[ $site_locale ] ) ) { - $lang_name = $available_languages[ $site_locale ]['english_name']; - } elseif ( \function_exists( 'locale_get_display_name' ) ) { - $lang_name = \locale_get_display_name( $site_locale, 'en' ); - } else { - $lang_name = $site_locale; - } - ?> - -

    -

    -

    - -

    -

    - -

    -

    - -

    -

    -

    - - -
    -
    - -
    - -
    - - - -
    - - - -
    - - -

    - Preview: %s', 'progress-planner' ) ), - '' . \esc_html( \date_i18n( \get_option( 'date_format' ) ) ) . '' - ); - ?> -

    -
    -
    - print_submit_button( \__( 'Set date format', 'progress-planner' ), 'prpl-steps-nav-wrapper-align-left' ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - date_format: (string) The date format to update. - * - date_format_custom: (string) The custom date format to update. - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! empty( $_POST['date_format'] ) && isset( $_POST['date_format_custom'] ) - && '\c\u\s\t\o\m' === \wp_unslash( $_POST['date_format'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We're not processing any data, here. - ) { - $_POST['date_format'] = $_POST['date_format_custom']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- sanitize_text_field() will sanitize the value. - } - - $date_format = \sanitize_text_field( \wp_unslash( $_POST['date_format'] ) ); - - if ( empty( $date_format ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid timezone.', 'progress-planner' ) ] ); - } - - // We're not checking for the return value of the update_option calls, because it will return false if the value is the same. - \update_option( 'date_format', $date_format ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $action_label = 'wp_default' === $this->get_date_format_type() ? \esc_html__( 'Set date format', 'progress-planner' ) : \esc_html__( 'Verify date format', 'progress-planner' ); - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html( $action_label ) . '', - ]; - - return $actions; - } - - /** - * Checks to which format the date is set. - * - 'F j, Y' - WP default with US-en locale. - * - __( ''F j, Y' ), localized version of the default date format. - * - non default date format. - * - * @return string - */ - protected function get_date_format_type() { - $date_format = \get_option( 'date_format' ); - - if ( $date_format === 'F j, Y' && 'F j, Y' !== \__( 'F j, Y' ) ) { // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- We want localized date format from WP Core. - return 'wp_default'; - } - - if ( $date_format === \__( 'F j, Y' ) ) { // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- We want localized date format from WP Core. - return 'localized_default'; - } - - return 'non_default'; - } -} diff --git a/classes/suggested-tasks/providers/class-set-page-about.php b/classes/suggested-tasks/providers/class-set-page-about.php deleted file mode 100644 index fbf9aa8d66..0000000000 --- a/classes/suggested-tasks/providers/class-set-page-about.php +++ /dev/null @@ -1,58 +0,0 @@ -'; - \esc_html_e( 'Your About page tells your story. It tells your visitors who you are, what your business is, and why your website exists. It humanizes your business by telling visitors about yourself and your team.', 'progress-planner' ); - echo '

    '; - echo '

    '; - \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' ); - echo '

    '; - } -} diff --git a/classes/suggested-tasks/providers/class-set-page-contact.php b/classes/suggested-tasks/providers/class-set-page-contact.php deleted file mode 100644 index 235a0e6fc7..0000000000 --- a/classes/suggested-tasks/providers/class-set-page-contact.php +++ /dev/null @@ -1,58 +0,0 @@ -'; - \esc_html_e( 'A strong contact page is essential for capturing leads and enhancing customer service.', 'progress-planner' ); - echo '

    '; - echo '

    '; - \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' ); - echo '

    '; - } -} diff --git a/classes/suggested-tasks/providers/class-set-page-faq.php b/classes/suggested-tasks/providers/class-set-page-faq.php deleted file mode 100644 index 20f0e1c336..0000000000 --- a/classes/suggested-tasks/providers/class-set-page-faq.php +++ /dev/null @@ -1,58 +0,0 @@ -'; - \esc_html_e( 'An FAQ page is essential for quickly answering your visitors’ most common questions. It’s beneficial for e-commerce sites, where customers frequently have questions about products, orders, and return policies.', 'progress-planner' ); - echo '

    '; - echo '

    '; - \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' ); - echo '

    '; - } -} diff --git a/classes/suggested-tasks/providers/class-set-page-task.php b/classes/suggested-tasks/providers/class-set-page-task.php deleted file mode 100644 index 27e8899675..0000000000 --- a/classes/suggested-tasks/providers/class-set-page-task.php +++ /dev/null @@ -1,199 +0,0 @@ -get_admin__enqueue()->enqueue_script( - 'progress-planner/recommendations/set-page', - $this->get_enqueue_data() - ); - self::$script_enqueued = true; - } - } - - /** - * Check if the task condition is satisfied. - * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - * - * @return bool - */ - public function should_add_task() { - $pages = \progress_planner()->get_admin__page_settings()->get_settings(); - - if ( ! isset( $pages[ static::PAGE_NAME ] ) ) { - return false; - } - - return 'no' === $pages[ static::PAGE_NAME ]['isset']; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $pages = \progress_planner()->get_admin__page_settings()->get_settings(); - $page = $pages[ static::PAGE_NAME ]; - - \progress_planner()->the_view( - 'setting/page-select.php', - [ - 'prpl_setting' => $page, - 'context' => 'popover', - ] - ); - $this->print_submit_button( \__( 'Set page', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change core permalink settings. - * The $_POST data is expected to be: - * - have_page: (string) The value to update the setting to. - * - id: (int) The ID of the page to update. - * - task_id: (string) The task ID (e.g., "set-page-about") to identify the page type. - * - nonce: (string) The nonce. - * - * @return void - */ - public static function handle_interactive_task_specific_submit() { - - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['have_page'] ) || ! isset( $_POST['task_id'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); - } - - $have_page = \trim( \sanitize_text_field( \wp_unslash( $_POST['have_page'] ) ) ); - $id = isset( $_POST['id'] ) ? (int) \trim( \sanitize_text_field( \wp_unslash( $_POST['id'] ) ) ) : 0; - $task_id = \trim( \sanitize_text_field( \wp_unslash( $_POST['task_id'] ) ) ); - - if ( empty( $have_page ) || empty( $task_id ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid page value.', 'progress-planner' ) ] ); - } - - // Extract page name from task ID (e.g., "set-page-about" -> "about"). - $page_name = \str_replace( 'set-page-', '', $task_id ); - if ( empty( $page_name ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid task ID.', 'progress-planner' ) ] ); - } - - // Validate page name against allowed page types. - $pages = \progress_planner()->get_admin__page_settings()->get_settings(); - if ( ! isset( $pages[ $page_name ] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid page name.', 'progress-planner' ) ] ); - } - - // Update the page value. - \progress_planner()->get_admin__page_settings()->set_page_values( - [ - $page_name => [ - 'id' => (int) $id, - 'have_page' => $have_page, // yes, no, not-applicable. - ], - ] - ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Page updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Set', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-set-valuable-post-types.php b/classes/suggested-tasks/providers/class-set-valuable-post-types.php deleted file mode 100644 index 35d4f30ae4..0000000000 --- a/classes/suggested-tasks/providers/class-set-valuable-post-types.php +++ /dev/null @@ -1,249 +0,0 @@ -get_settings()->get_public_post_types() ); - - // Sort the public post types. - \sort( $previosly_set_public_post_types ); - \sort( $public_post_types ); - - // Compare the previously set public post types with the current public post types. - if ( $previosly_set_public_post_types === $public_post_types ) { - return; - } - - // Update the previously set public post types. - \update_option( 'progress_planner_public_post_types', $public_post_types ); - - // Exit if post type was removed, or it is not public anymore, since the user will not to able to make different selection. - if ( count( $public_post_types ) < count( $previosly_set_public_post_types ) ) { - return; - } - - // If we're here that means that there is new public post type. - - // Check if the task exists, if it does and it is published do nothing. - $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => static::PROVIDER_ID ] ); - if ( ! empty( $task ) && 'publish' === $task[0]->post_status ) { - return; - } - - // If it is trashed, change it's status to publish. - if ( ! empty( $task ) && 'trash' === $task[0]->post_status ) { - \wp_update_post( - [ - 'ID' => $task[0]->ID, - 'post_status' => 'publish', - ] - ); - return; - } - - // If we're here then we need to add it. - \progress_planner()->get_suggested_tasks_db()->add( $this->modify_injection_task_data( $this->get_task_details() ) ); - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Set valuable content types', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * We add tasks only to users who have upgraded from v1.2 or have 'include_post_types' option empty. - * Reason being that this option was migrated, - * but it could be missed, and post type selection should be revisited. - * - * @return bool - */ - public function should_add_task() { - $activity = \progress_planner()->get_activities__query()->query_activities( - [ - 'category' => 'suggested_task', - 'data_id' => static::PROVIDER_ID, - ] - ); - if ( ! empty( $activity ) ) { - return false; - } - - // Upgraded from <= 1.2? - $upgraded = (bool) \get_option( 'progress_planner_set_valuable_post_types', false ); - - // Include post types option empty? - $include_post_types = \progress_planner()->get_settings()->get( 'include_post_types', [] ); - - // Add the task only to users who have upgraded from v1.2 or have 'include_post_types' option empty. - return ( true === $upgraded || empty( $include_post_types ) ); - } - - /** - * Check if the task is completed. - * We are checking the 'is_task_completed' method only if the task was added previously. - * If it was and the option is not set it means that user has completed the task. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - return false === \get_option( 'progress_planner_set_valuable_post_types', false ); - } - - /** - * Handle the interactive task submit. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - // Check if the user has the necessary capabilities. - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['prpl-post-types-include'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing post types.', 'progress-planner' ) ] ); - } - - $post_types = \wp_unslash( $_POST['prpl-post-types-include'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- array elements are sanitized below. - $post_types = explode( ',', $post_types ); - $post_types = array_map( 'sanitize_text_field', $post_types ); - - \progress_planner()->get_admin__page_settings()->save_post_types( $post_types ); - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Print the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'You\'re in control of what counts as valuable content. We\'ll track and reward activity only for the post types you select here.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - $prpl_saved_settings = \progress_planner()->get_settings()->get_post_types_names(); - $prpl_post_types = \progress_planner()->get_settings()->get_public_post_types(); - - // Early exit if there are no public post types. - if ( empty( $prpl_post_types ) ) { - return; - } - ?> -
    - - - -
    - print_submit_button( \__( 'Set', '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__( 'Set', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php deleted file mode 100644 index 7dfd56f88d..0000000000 --- a/classes/suggested-tasks/providers/class-site-icon.php +++ /dev/null @@ -1,195 +0,0 @@ - 'options-general.php', - 'iconEl' => '.site-icon-section th', - ]; - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return \admin_url( 'options-general.php?pp-focus-el=' . $this->get_task_id() ); - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Set site icon', 'progress-planner' ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $site_icon = \get_option( 'site_icon' ); - return '' === $site_icon || '0' === $site_icon; - } - - /** - * Print the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'Site Icons are what you see in browser tabs, bookmark bars, and within the WordPress mobile apps. Upload an image to make your site stand out.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - // Enqueue media scripts. - \wp_enqueue_media(); - - $site_icon_id = \get_option( 'site_icon' ); - ?> -
    - - 'max-width: 150px; height: auto; border-radius: 4px; border: 1px solid #ddd;' ] ); ?> - - - -
    - - -
    - -
    - 'prplSiteIcon', - 'data' => [ - 'mediaTitle' => \esc_html__( 'Choose Site Icon', 'progress-planner' ), - 'mediaButtonText' => \esc_html__( 'Use as Site Icon', '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 \__( '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-interactive.php b/classes/suggested-tasks/providers/class-tasks-interactive.php deleted file mode 100644 index a82278f776..0000000000 --- a/classes/suggested-tasks/providers/class-tasks-interactive.php +++ /dev/null @@ -1,299 +0,0 @@ - \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['value'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['setting_path'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting path.', 'progress-planner' ) ] ); - } - - $setting = \sanitize_text_field( \wp_unslash( $_POST['setting'] ) ); - $value = \sanitize_text_field( \wp_unslash( $_POST['value'] ) ); - $setting_path = \json_decode( \sanitize_text_field( \wp_unslash( $_POST['setting_path'] ) ), true ); - - // Whitelist allowed options to prevent arbitrary options update. - // This prevents privilege escalation by restricting which options can be updated. - $allowed_options = $this->get_allowed_interactive_options(); - - if ( ! \in_array( $setting, $allowed_options, true ) ) { - \wp_send_json_error( - [ - 'message' => \esc_html__( 'Invalid setting. This option cannot be updated through interactive tasks.', 'progress-planner' ), - ] - ); - } - - if ( ! empty( $setting_path ) ) { - $setting_value = \get_option( $setting ); - \_wp_array_set( $setting_value, $setting_path, $value ); - $updated = \update_option( $setting, $setting_value ); - if ( ! $updated ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', 'progress-planner' ) ] ); - } - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - $updated = \update_option( $setting, $value ); - if ( ! $updated ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', 'progress-planner' ) ] ); - } - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Get the list of allowed options that can be updated via interactive tasks. - * - * This whitelist prevents privilege escalation by ensuring only specific - * WordPress options can be modified through the interactive task interface. - * - * @return array List of allowed option names. - */ - protected function get_allowed_interactive_options() { - $allowed_options = [ - // Core WordPress settings that are safe to update via interactive tasks. - 'blogdescription', // Site tagline. - 'default_comment_status', // Comment settings. - 'default_ping_status', // Pingback settings. - 'timezone_string', // Site timezone. - 'WPLANG', // Site language/locale (deprecated since WP 4.0, but still used by class-select-locale.php). - 'date_format', // Date format. - 'time_format', // Time format. - 'default_pingback_flag', // Pingback flag. - 'comment_registration', // Comment registration. - 'close_comments_for_old_posts', // Close comments for old posts. - 'thread_comments', // Threaded comments. - 'comments_per_page', // Comments per page. - 'comment_order', // Comment order. - 'page_comments', // Paginate comments. - ]; - - /** - * Filter the list of allowed options for interactive tasks. - * - * WARNING: Be very careful when extending this list. Adding sensitive - * options like 'admin_email', 'users_can_register', or plugin-specific - * options that control access or permissions could create security vulnerabilities. - * - * @param array $allowed_options List of allowed option names. - * - * @return array Modified list of allowed option names. - */ - return \apply_filters( 'progress_planner_interactive_task_allowed_options', $allowed_options ); - } - - /** - * Add the popover. - * - * @return void - */ - public function add_popover() { - - // Don't add the popover if the task is not published. - if ( ! $this->is_task_published() ) { - return; - } - ?> -
    - the_popover_content(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
    - the_view( - [ - '/views/popovers/' . static::POPOVER_ID . '.php', - '/views/popovers/interactive-task.php', - ], - [ - 'prpl_task_object' => $this, - 'prpl_popover_id' => static::POPOVER_ID, - 'prpl_external_link_url' => $this->get_external_link_url(), - 'prpl_provider_id' => $this->get_provider_id(), - 'prpl_task_actions' => $this->get_task_actions(), - ] - ); - } - - /** - * Print the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - $description = $this->get_description(); - if ( empty( $description ) ) { - return; - } - - echo '

    ' . \wp_kses_post( $description ) . '

    '; - } - - /** - * Print the submit button. - * - * @param string $button_text The text for the button. - * If empty, the default text "Submit" will be used. - * @param string $css_class The CSS class for the wrapper. - * - * @return void - */ - protected function print_submit_button( $button_text = '', $css_class = '' ) { - if ( empty( $button_text ) ) { - $button_text = \__( 'Submit', 'progress-planner' ); - } - - $css_class = empty( $css_class ) ? 'prpl-steps-nav-wrapper' : 'prpl-steps-nav-wrapper ' . $css_class; - ?> -
    - -
    - is_task_published() ) { - return; - } - - // Enqueue the web component. - \progress_planner()->get_admin__enqueue()->enqueue_script( - 'progress-planner/recommendations/' . $this->get_provider_id(), - $this->get_enqueue_data() - ); - } - - /** - * Get the enqueue data. - * - * @return array - */ - protected function get_enqueue_data() { - return []; - } - - /** - * Check if the task is published. - * - * @return bool - */ - public function is_task_published() { - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( - [ - 'provider' => $this->get_provider_id(), - 'post_status' => 'publish', - ] - ); - return ! empty( $tasks ); - } -} diff --git a/classes/suggested-tasks/providers/class-tasks.php b/classes/suggested-tasks/providers/class-tasks.php deleted file mode 100644 index d074ed83a9..0000000000 --- a/classes/suggested-tasks/providers/class-tasks.php +++ /dev/null @@ -1,827 +0,0 @@ - - */ - protected const DEPENDENCIES = []; - - /** - * The external link URL. - * - * @var string - */ - protected const EXTERNAL_LINK_URL = ''; - - /** - * The popover ID. - * - * @var string - */ - protected const POPOVER_ID = ''; - - /** - * Whether the task is repetitive. - * - * @var bool - */ - protected $is_repetitive = false; - - /** - * The task points. - * - * @var int - */ - protected $points = 1; - - /** - * The task parent. - * - * @var int - */ - protected $parent = 0; - - /** - * The task priority. - * - * Tasks are ordered from lowest to highest priority value (0 = highest priority, 100 = lowest priority). - * Use the PRIORITY_* constants defined in this class for consistency. - * - * @var int - */ - protected $priority = 50; - - /** - * Whether the task is dismissable. - * - * @var bool - */ - protected $is_dismissable = false; - - /** - * Whether the task is snoozable. - * - * @var bool - */ - protected $is_snoozable = true; - - /** - * The task URL. - * - * @var string - */ - protected $url = ''; - - /** - * The task URL target. - * - * @var string - */ - protected $url_target = '_self'; - - /** - * The task link setting. - * - * @var array - */ - protected $link_setting; - - /** - * The data collector. - * - * @var \Progress_Planner\Suggested_Tasks\Data_Collector\Base_Data_Collector|null - */ - protected $data_collector = null; - - /** - * Initialize the task provider. - * - * @return void - */ - public function init() { - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return ''; - } - - /** - * Get the task description. - * - * @return string - */ - protected function get_description() { - return ''; - } - - /** - * Get the task points. - * - * @return int - */ - public function get_points() { - return $this->points; - } - - /** - * Get the task parent. - * - * @return int - */ - public function get_parent() { - return $this->parent; - } - - /** - * Get the popover ID. - * - * @return string - */ - public function get_popover_id() { - return static::POPOVER_ID; - } - - /** - * Get the task priority. - * - * @return int - */ - public function get_priority() { - return (int) $this->priority; - } - - /** - * Get whether the task is dismissable. - * - * @return bool - */ - public function is_dismissable() { - return $this->is_dismissable; - } - - /** - * Get whether the task is snoozable. - * - * @return bool - */ - public function is_snoozable() { - return $this->is_snoozable; - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return $this->url ? \esc_url( $this->url ) : ''; - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url_target() { - return $this->url_target ? $this->url_target : '_self'; - } - - /** - * Get the task link setting. - * - * @return array - */ - public function get_link_setting() { - return $this->link_setting; - } - - /** - * Get the provider ID. - * - * @return string - */ - public function get_provider_id() { - return static::PROVIDER_ID; - } - - /** - * Get external link URL. - * - * @return string - */ - public function get_external_link_url() { - return \progress_planner()->get_ui__branding()->get_url( static::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 (YW format) to create weekly instances. - * - * Example task IDs: - * - Non-repetitive: "update-core" - * - With post target: "update-post-123" - * - With term target: "update-term-5-category" - * - Repetitive weekly: "create-post-2025W42" - * - * @param array $task_data { - * Optional data to include in the task ID. - * - * @type int $target_post_id The ID of the post this task targets. - * @type int $target_term_id The ID of the term this task targets. - * @type string $target_taxonomy The taxonomy slug for term-based tasks. - * } - * @return string The generated task ID (e.g., "provider-id-123-202542"). - */ - public function get_task_id( $task_data = [] ) { - $parts = [ $this->get_provider_id() ]; - - // Order is important here, new parameters should be added at the end. - // This ensures existing task IDs remain consistent when new fields are added. - $parts[] = $task_data['target_post_id'] ?? false; - $parts[] = $task_data['target_term_id'] ?? false; - $parts[] = $task_data['target_taxonomy'] ?? false; - // If the task is repetitive, add the date as the last part (format: YYYYWW, e.g., 202542 for week 42 of 2025). - // This creates a new task instance each week for repetitive tasks. - // Note: We use 'oW' format (ISO year + ISO week) instead of 'YW' to handle year boundaries correctly. - // For example, Dec 29, 2025 is ISO week 01 of 2026, so 'oW' returns '202601' while 'YW' would incorrectly return '202501'. - $parts[] = $this->is_repetitive() ? \gmdate( 'oW' ) : false; - - // Remove empty parts to keep IDs clean. - $parts = \array_filter( $parts ); - - return \implode( '-', $parts ); - } - - /** - * Get the data collector. - * - * @return \Progress_Planner\Suggested_Tasks\Data_Collector\Base_Data_Collector - */ - public function get_data_collector() { - if ( ! $this->data_collector ) { - $class_name = static::DATA_COLLECTOR_CLASS; - $this->data_collector = new $class_name(); // @phpstan-ignore-line assign.propertyType - } - - return $this->data_collector; // @phpstan-ignore-line return.type - } - - /** - * Get the title with data. - * - * Allows child classes to generate dynamic task titles based on task-specific data. - * For example, "Update post: {post_title}" where {post_title} comes from $task_data. - * - * @param array $task_data { - * Optional data to include in the task title. - * - * @type int $target_post_id The ID of the post this task targets. - * @type string $target_post_title The title of the post this task targets. - * @type int $target_term_id The ID of the term this task targets. - * @type string $target_term_name The name of the term this task targets. - * @type string $target_taxonomy The taxonomy slug for term-based tasks. - * } - * @return string The task title. - */ - protected function get_title_with_data( $task_data = [] ) { - return $this->get_title(); - } - - /** - * Get the description with data. - * - * Allows child classes to generate dynamic task descriptions based on task-specific data. - * - * @param array $task_data { - * Optional data to include in the task description. - * - * @type int $target_post_id The ID of the post this task targets. - * @type string $target_post_title The title of the post this task targets. - * @type int $target_term_id The ID of the term this task targets. - * @type string $target_term_name The name of the term this task targets. - * @type string $target_taxonomy The taxonomy slug for term-based tasks. - * } - * @return string The task description. - */ - protected function get_description_with_data( $task_data = [] ) { - return $this->get_description(); - } - - /** - * Get the URL with data. - * - * Allows child classes to generate dynamic task URLs based on task-specific data. - * For example, a link to edit a specific post: "post.php?post={post_id}&action=edit". - * - * @param array $task_data { - * Optional data to include in generating the task URL. - * - * @type int $target_post_id The ID of the post this task targets. - * @type int $target_term_id The ID of the term this task targets. - * @type string $target_taxonomy The taxonomy slug for term-based tasks. - * } - * @return string The task URL (escaped and ready to use). - */ - protected function get_url_with_data( $task_data = [] ) { - return $this->get_url(); - } - - /** - * Check if the user has the capability to perform the task. - * - * @return bool - */ - public function capability_required() { - return static::CAPABILITY - ? \current_user_can( static::CAPABILITY ) - : true; - } - - /** - * Check if the task is a repetitive task. - * - * @return bool - */ - public function is_repetitive() { - return $this->is_repetitive; - } - - /** - * Check if the task is an onboarding task. - * - * @return bool - */ - public function is_onboarding_task() { - return static::IS_ONBOARDING_TASK; - } - - /** - * Check if task provider is snoozed. - * - * @return bool - */ - public function is_task_snoozed() { - foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'future' ] ) as $task ) { - $task = \progress_planner()->get_suggested_tasks_db()->get_post( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ); - $provider_id = $task ? $task->get_provider_id() : ''; - - if ( $provider_id === $this->get_provider_id() ) { - return true; - } - } - - return false; - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - return true; - } - - /** - * Evaluate a task to check if it has been completed. - * - * This method determines whether a task should be marked as completed and earn points. - * It handles both non-repetitive tasks (one-time) and repetitive tasks (weekly). - * - * Non-repetitive tasks: - * - Checks if the task belongs to this provider - * - Verifies completion status via is_task_completed() - * - Returns the task object if completed, false otherwise - * - * Repetitive tasks: - * - Must be completed within the same week they were created (using oW format: ISO year + ISO week number) - * - For example, a task created in week 42 of 2025 must be completed in 2025W42 - * - This prevents tasks from previous weeks being marked as complete - * - Allows child classes to add completion data (e.g., post_id for "create post" tasks) - * - * @param string $task_id The task ID to evaluate. - * - * @return \Progress_Planner\Suggested_Tasks\Task|false The task object if completed, false otherwise. - */ - public function evaluate_task( $task_id ) { - // Early bail if the user does not have the capability to manage options. - if ( ! $this->capability_required() ) { - return false; - } - - $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); - - if ( ! $task ) { - return false; - } - - // Handle non-repetitive (one-time) tasks. - if ( ! $this->is_repetitive() ) { - // Collaborator tasks have custom task_ids, so strpos check does not work for them. - if ( ! $task->post_name || ( 0 !== \strpos( $task->post_name, $this->get_task_id() ) && 'collaborator' !== $this->get_provider_id() ) ) { - return false; - } - return $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ? $task : false; - } - - // Handle repetitive (weekly) tasks. - // These tasks must be completed in the same week they were created. - if ( - $task->provider && - $task->provider->slug === $this->get_provider_id() && - \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date ) && - // Check if the task was created in the current week (oW format: ISO year + ISO week, e.g., 202542 = week 42 of 2025). - \gmdate( 'oW' ) === \gmdate( 'oW', \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date )->getTimestamp() ) && // @phpstan-ignore-line - $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) - ) { - // Allow adding more data, for example in case of 'create-post' tasks we are adding the post_id. - $task_data = $this->modify_evaluated_task_data( $task->get_data() ); - $task->update( $task_data ); - - return $task; - } - - return false; - } - - /** - * Check if the task condition is satisfied. - * - * @return bool true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed. - */ - abstract public function should_add_task(); - - /** - * Alias for should_add_task(), for better readability when using in the evaluate_task() method. - * - * @param string $task_id Optional task ID to check completion for. - * @return bool - */ - public function is_task_completed( $task_id = '' ) { - // If no specific task ID provided, use the default behavior. - return empty( $task_id ) - ? ! $this->should_add_task() - : $this->is_specific_task_completed( $task_id ); - } - - /** - * Check if a specific task is completed. - * Child classes can override this method to handle specific task IDs. - * - * @param string $task_id The task ID to check. - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - return ! $this->should_add_task(); - } - - /** - * Backwards-compatible method to check if the task condition is satisfied. - * - * @return bool - */ - protected function check_task_condition() { - return ! $this->should_add_task(); - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( - true === $this->is_task_snoozed() || - ! $this->should_add_task() || // No need to add the task. - true === \progress_planner()->get_suggested_tasks()->was_task_completed( $this->get_task_id() ) - ) { - return []; - } - - $task_data = $this->modify_injection_task_data( $this->get_task_details() ); - - // Skip the task if it was already injected. - return \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ) - ? [] - : [ \progress_planner()->get_suggested_tasks_db()->add( $task_data ) ]; - } - - /** - * Modify task data before injecting it. - * Child classes can override this method to add extra data. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_injection_task_data( $task_data ) { - return $task_data; - } - - /** - * Modify task data after task was evaluated. - * Child classes can override this method to add extra data. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_evaluated_task_data( $task_data ) { - return $task_data; - } - - /** - * Get the task details. - * - * @param array $task_data The task data. - * - * @return array - */ - public function get_task_details( $task_data = [] ) { - return [ - 'task_id' => $this->get_task_id( $task_data ), - 'provider_id' => $this->get_provider_id(), - 'post_title' => $this->get_title_with_data( $task_data ), - 'description' => $this->get_description_with_data( $task_data ), - 'parent' => $this->get_parent(), - 'priority' => $this->get_priority(), - 'points' => $this->get_points(), - 'date' => \gmdate( 'oW' ), - 'url' => $this->get_url_with_data( $task_data ), - 'url_target' => $this->get_url_target(), - 'link_setting' => $this->get_link_setting(), - 'dismissable' => $this->is_dismissable(), - 'external_link_url' => $this->get_external_link_url(), - ]; - } - - /** - * Transform data collector data into task data format. - * - * @param array $data The data from data collector. - * @return array The transformed data with original data merged. - */ - protected function transform_collector_data( array $data ): array { - $transform_keys = [ - 'term_id' => 'target_term_id', - 'taxonomy' => 'target_taxonomy', - 'name' => 'target_term_name', - 'post_id' => 'target_post_id', - 'post_title' => 'target_post_title', - ]; - - foreach ( $transform_keys as $key => $value ) { - if ( isset( $data[ $key ] ) ) { - $data[ $value ] = $data[ $key ]; - } - } - - return $data; - } - - /** - * Check if the task dependencies are satisfied. - * - * @return bool - */ - public function are_dependencies_satisfied() { - foreach ( static::DEPENDENCIES as $task_id => $result ) { - $post = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); - if ( ! $post ) { - return false; - } - $post_status = $post->post_status; - if ( ( 'publish' === $post_status && ! $result ) - || ( 'publish' !== $post_status && $result ) - ) { - return false; - } - } - - return true; - } - - /** - * Get task actions HTML buttons/links for display in the UI. - * - * Generates an array of HTML action buttons that users can interact with for each task. - * Actions are ordered by priority (lower numbers appear first). - * - * Standard actions include: - * - Complete button (priority 20): Marks task as complete and awards points - * - Snooze button (priority 30): Postpones task for specified duration (1 week to forever) - * - Info/External link (priority 40): Educational content about the task - * - Custom actions: Child classes can add via add_task_actions() - * - * Priority system (0-100, lower = higher priority): - * - 0-19: Reserved for critical actions - * - 20: Complete action - * - 30: Snooze action - * - 40: Information/educational links - * - 50+: Custom provider-specific actions - * - 1000: Default for actions without explicit priority - * - * @param array $data { - * The task data from the REST API response. - * - * @type int $id The WordPress post ID of the task. - * @type string $slug The task slug (post_name). - * @type array $title { - * @type string $rendered The rendered task title. - * } - * @type array $content { - * @type string $rendered The rendered task description/content. - * } - * @type array $meta Task metadata (presence checked before processing). - * } - * - * @return array Array of HTML strings for action buttons/links, ordered by priority. - */ - public function get_task_actions( $data = [] ) { - $actions = []; - if ( ! isset( $data['meta'] ) ) { - return $actions; - } - - // Add "Mark as complete" button for dismissable tasks (except user-created tasks). - if ( $this->capability_required() && $this->is_dismissable() && 'user' !== static::PROVIDER_ID ) { - $actions[] = [ - 'priority' => 20, - 'html' => '', - ]; - } - - // Add "Snooze" button with duration options for snoozable tasks. - if ( $this->capability_required() && $this->is_snoozable() ) { - // Build snooze dropdown with custom web component (prpl-tooltip). - $snooze_html = ''; - $snooze_html .= '
    ' . \esc_html__( 'Snooze this task?', 'progress-planner' ) . '
    '; - - // Generate radio buttons for snooze duration options. - foreach ( - [ - '1-week' => \esc_html__( '1 week', 'progress-planner' ), - '1-month' => \esc_html__( '1 month', 'progress-planner' ), - '3-months' => \esc_html__( '3 months', 'progress-planner' ), - '6-months' => \esc_html__( '6 months', 'progress-planner' ), - '1-year' => \esc_html__( '1 year', 'progress-planner' ), - 'forever' => \esc_html__( 'forever', 'progress-planner' ), - ] as $snooze_key => $snooze_value ) { - $snooze_html .= ''; - } - $snooze_html .= '
    '; - $actions[] = [ - 'priority' => 30, - 'html' => $snooze_html, - ]; - } - - // Add educational/informational links. - // Prefer external links if provided, otherwise show task description in tooltip. - if ( $this->get_external_link_url() ) { - $actions[] = [ - 'priority' => 40, - 'html' => '' . \esc_html__( 'Why is this important?', 'progress-planner' ) . '', - ]; - } elseif ( isset( $data['content']['rendered'] ) && $data['content']['rendered'] !== '' && ! $this instanceof Tasks_Interactive ) { - $actions[] = [ - 'priority' => 40, - 'html' => '' . \wp_kses_post( $data['content']['rendered'] ) . '', - ]; - } - - // Allow child classes to add custom actions (e.g., "Edit Post" for content tasks). - if ( $this->capability_required() ) { - $actions = $this->add_task_actions( $data, $actions ); - - // Ensure all actions have priority set and remove empty actions. - foreach ( $actions as $key => $action ) { - $actions[ $key ]['priority'] = $action['priority'] ?? 1000; - if ( ! isset( $action['html'] ) || '' === $action['html'] ) { - unset( $actions[ $key ] ); - } - } - } - - // Sort actions by priority (ascending: lower priority values appear first). - \usort( $actions, fn( $a, $b ) => $a['priority'] - $b['priority'] ); - - // Extract just the HTML strings (discard priority metadata). - $return_actions = []; - foreach ( $actions as $action ) { - $return_actions[] = $action['html']; - } - - return $return_actions; - } - - /** - * Get the task actions. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - 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. - * - * @param string $task_id The task ID. - * - * @return bool - */ - public function task_has_activity( $task_id = '' ) { - if ( empty( $task_id ) ) { - $task_id = $this->get_task_id(); - } - - $activity = \progress_planner()->get_activities__query()->query_activities( - [ - 'category' => 'suggested_task', - 'data_id' => $task_id, - ] - ); - - return ! empty( $activity ); - } - - /** - * Complete the task. - * - * @param array $args The task data. - * @param string $task_id The task ID. - * - * @return bool - */ - public function complete_task( $args = [], $task_id = '' ) { - return false; - } -} diff --git a/classes/suggested-tasks/providers/class-unpublished-content.php b/classes/suggested-tasks/providers/class-unpublished-content.php deleted file mode 100644 index ba26be1fc8..0000000000 --- a/classes/suggested-tasks/providers/class-unpublished-content.php +++ /dev/null @@ -1,349 +0,0 @@ -init_dismissable_task(); - - \add_filter( 'progress_planner_unpublished_content_exclude_post_ids', [ $this, 'exclude_completed_posts' ] ); - } - - /** - * Get the task title. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_title_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_post_id'] ) ) { - return ''; - } - - $post = \get_post( $task_data['target_post_id'] ); - - if ( ! $post ) { - return ''; - } - - if ( empty( $post->post_title ) ) { - return \sprintf( - /* translators: %1$s: post type, %2$d: post ID */ - \esc_html__( 'Add a title to %1$s %2$d and finish it', 'progress-planner' ), - \strtolower( \get_post_type_object( \esc_html( $post->post_type ) )->labels->singular_name ), // @phpstan-ignore-line property.nonObject - (int) $post->ID - ); - } - - return \sprintf( - // translators: %1$s: The post type, %2$s: The post title. - \esc_html__( 'Finish %1$s "%2$s" and publish it', 'progress-planner' ), - \strtolower( \get_post_type_object( \esc_html( $post->post_type ) )->labels->singular_name ), // @phpstan-ignore-line property.nonObject - \esc_html( $post->post_title ) // @phpstan-ignore-line property.nonObject - ); - } - - /** - * Get the task URL. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_url_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_post_id'] ) ) { - return ''; - } - - $post = \get_post( $task_data['target_post_id'] ); - - if ( ! $post ) { - return ''; - } - - // We don't use the edit_post_link() function because we need to bypass it's current_user_can() check. - return \esc_url( - \add_query_arg( - [ - 'post' => $post->ID, - 'action' => 'edit', - ], - \admin_url( 'post.php' ) - ) - ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return ! empty( $this->get_data_collector()->collect() ); - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( true === $this->is_task_snoozed() || ! $this->should_add_task() ) { - return []; - } - - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - $task_id = $this->get_task_id( - [ - 'target_post_id' => $data['target_post_id'], - ] - ); - - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_id ) ) { - return []; - } - - // Transform the data to match the task data structure. - $task_data = $this->modify_injection_task_data( - $this->get_task_details( - $data - ) - ); - - // Get the task post. - $task_post = \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ); - - // Skip the task if it was already injected. - if ( $task_post ) { - return []; - } - - return [ \progress_planner()->get_suggested_tasks_db()->add( $task_data ) ]; - } - - /** - * Modify task data before injecting it. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_injection_task_data( $task_data ) { - // Transform the data to match the task data structure. - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - - $task_data['target_post_id'] = $data['target_post_id']; - - return $task_data; - } - - /** - * This method is added just to override the parent method. - * For this task provider we can't check if it is snoozed like for other as we snooze the task for specific post. - * Check for that is included in the should_add_task method. - * - * @return bool - */ - public function is_task_snoozed() { - return false; - } - - /** - * Exclude completed posts from the query. - * - * @param array $post_ids The post IDs. - * @return array - */ - public function exclude_completed_posts( $post_ids ) { - return \array_merge( $post_ids, $this->get_snoozed_post_ids(), $this->get_dismissed_post_ids() ); - } - - /** - * Get the snoozed post IDs. - * - * @return array - */ - protected function get_snoozed_post_ids() { - if ( null !== $this->snoozed_post_ids ) { - return $this->snoozed_post_ids; - } - - $this->snoozed_post_ids = []; - $snoozed = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'post_status' => 'future' ] ); - - if ( ! empty( $snoozed ) ) { - foreach ( $snoozed as $task ) { - /** - * The task object. - * - * @var \Progress_Planner\Suggested_Tasks\Task $task - */ - if ( isset( $task->provider->slug ) && 'unpublished-content' === $task->provider->slug ) { - $this->snoozed_post_ids[] = $task->target_post_id; - } - } - } - - return $this->snoozed_post_ids; - } - - /** - * Get the dismissed post IDs. - * - * @return array - */ - protected function get_dismissed_post_ids() { - if ( null !== $this->dismissed_post_ids ) { - return $this->dismissed_post_ids; - } - - $this->dismissed_post_ids = []; - $dismissed = $this->get_dismissed_tasks(); - - if ( ! empty( $dismissed ) ) { - $this->dismissed_post_ids = \array_values( \wp_list_pluck( $dismissed, 'post_id' ) ); - } - - return $this->dismissed_post_ids; - } - - /** - * Get the task identifier for storing dismissal data. - * Override this method in the implementing class to provide task-specific identification. - * - * @param array $task_data The task data. - * - * @return string|false The task identifier or false if not applicable. - */ - protected function get_task_identifier( $task_data ) { - return $this->get_provider_id() . '-' . $task_data['target_post_id']; - } - - /** - * Check if a specific task is completed. - * - * @param string $task_id The task ID to check. - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id ); - - if ( ! $task ) { - return false; - } - - $data = $task->get_data(); - - if ( ! $data || ! isset( $data['target_post_id'] ) ) { - return false; - } - - $post_status = \get_post_status( $data['target_post_id'] ); - - // If the post status is not draft or auto-draft (this includes (bool) false when the post was deleted), the task is completed. - return ( 'draft' !== $post_status && 'auto-draft' !== $post_status ); - } - - /** - * 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 = [] ) { - if ( ! isset( $data['meta']['prpl_url'] ) ) { - return $actions; - } - - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Edit', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-update-term-description.php b/classes/suggested-tasks/providers/class-update-term-description.php deleted file mode 100644 index 455a5675b7..0000000000 --- a/classes/suggested-tasks/providers/class-update-term-description.php +++ /dev/null @@ -1,463 +0,0 @@ -public ) { - return; - } - - $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ); - - if ( ! $pending_tasks ) { - return; - } - - foreach ( $pending_tasks as $task ) { - if ( $task->target_term_id && $task->target_taxonomy && (int) $task->target_term_id === (int) $deleted_term->term_id ) { - \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); - } - } - } - - /** - * Get the title. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_title_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { - return ''; - } - - $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); - return $term && ! \is_wp_error( $term ) ? \sprintf( - /* translators: %s: The term name */ - \esc_html__( 'Write a description for term named "%s"', 'progress-planner' ), - \esc_html( $term->name ) - ) : ''; - } - - /** - * Get the URL. - * - * @param array $task_data The task data. - * - * @return string - */ - protected function get_url_with_data( $task_data = [] ) { - if ( ! isset( $task_data['target_term_id'] ) || ! isset( $task_data['target_taxonomy'] ) ) { - return ''; - } - - $term = \get_term( $task_data['target_term_id'], $task_data['target_taxonomy'] ); - return $term && ! \is_wp_error( $term ) - ? \admin_url( 'term.php?taxonomy=' . $term->taxonomy . '&tag_ID=' . $term->term_id ) - : ''; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return ! empty( $this->get_data_collector()->collect() ); - } - - /** - * Check if a specific task is completed. - * Child classes can override this method to handle specific task IDs. - * - * @param string $task_id The task ID to check. - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - $term = $this->get_term_from_task_id( $task_id ); - - // Terms was deleted. - if ( ! $term ) { - return true; - } - - $term_description = \trim( $term->description ); - - return '' !== $term_description && ' ' !== $term_description; - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( true === $this->is_task_snoozed() || ! $this->should_add_task() ) { - return []; - } - - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - $task_id = $this->get_task_id( - [ - 'target_term_id' => $data['target_term_id'], - 'target_taxonomy' => $data['target_taxonomy'], - ] - ); - - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_id ) ) { - return []; - } - - // Transform the data to match the task data structure. - $task_data = $this->modify_injection_task_data( - $this->get_task_details( - $data - ) - ); - - // Skip the task if it was already injected. - if ( \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ) ) { - return []; - } - - return [ \progress_planner()->get_suggested_tasks_db()->add( $task_data ) ]; - } - - /** - * Modify task data before injecting it. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_injection_task_data( $task_data ) { - // Transform the data to match the task data structure. - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - - $task_data['target_term_id'] = $data['target_term_id']; - $task_data['target_taxonomy'] = $data['target_taxonomy']; - $task_data['target_term_name'] = $data['target_term_name']; - - return $task_data; - } - - /** - * Get the term from the task ID. - * - * @param string $task_id The task ID. - * - * @return \WP_Term|null - */ - public function get_term_from_task_id( $task_id ) { - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); - - if ( empty( $tasks ) ) { - return null; - } - - $task = $tasks[0]; - - if ( ! $task->target_term_id || ! $task->target_taxonomy ) { - return null; - } - - $term = \get_term( $task->target_term_id, $task->target_taxonomy ); - return $term && ! \is_wp_error( $term ) ? $term : null; - } - - /** - * Get the dismissed term IDs. - * - * @return array - */ - protected function get_completed_term_ids() { - if ( null !== $this->completed_term_ids ) { - return $this->completed_term_ids; - } - - $this->completed_term_ids = []; - - foreach ( \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ) as $task ) { - if ( 'trash' === $task->post_status ) { - $this->completed_term_ids[] = $task->target_term_id; - } - } - - return $this->completed_term_ids; - } - - /** - * Exclude completed terms. - * - * @param array $exclude_term_ids The excluded term IDs. - * @return array - */ - public function exclude_completed_terms( $exclude_term_ids ) { - return \array_merge( $exclude_term_ids, $this->get_completed_term_ids() ); - } - - /** - * 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 = [] ) { - if ( ! isset( $data['slug'] ) ) { - return $actions; - } - - $term = $this->get_term_from_task_id( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $data['slug'] ) ); - - // If the term is not found, return the actions. - if ( ! $term ) { - return $actions; - } - - $task_data = [ - 'target_term_id' => $term->term_id, - 'target_taxonomy' => $term->taxonomy, - 'target_term_name' => $term->name, - ]; - - $task_details = $this->get_task_details( $task_data ); - - $taxonomy = \get_taxonomy( $term->taxonomy ); - - $actions[] = [ - 'priority' => 10, - 'html' => \sprintf( - ' - %s - ', - \htmlspecialchars( - \wp_json_encode( - [ - 'post_title' => $task_details['post_title'], - 'target_term_id' => $task_data['target_term_id'], - 'target_taxonomy' => $task_data['target_taxonomy'], - 'target_taxonomy_name' => $taxonomy ? $taxonomy->label : '', - 'target_term_name' => $task_data['target_term_name'], - ] - ), - ENT_QUOTES, - 'UTF-8' - ), - \esc_attr( static::POPOVER_ID ), - \esc_html__( 'Write description', 'progress-planner' ) - ), - ]; - - return $actions; - } - - /** - * Print the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'Term descriptions appear on category and tag archive pages, helping visitors understand what to expect. They also provide important context for search engines, which can improve your SEO.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover form contents. - * - * @return void - */ - public function print_popover_form_contents() { - ?> -
    -

    - - -

    -

    - ', - '' - ); - ?> -

    -
    - - - -
    - -
    - \esc_html__( 'You do not have permission to update terms.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - if ( ! isset( $_POST['term_id'] ) || ! isset( $_POST['taxonomy'] ) || ! isset( $_POST['description'] ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Missing term information.', 'progress-planner' ) ] ); - } - - $term_id = \absint( \wp_unslash( $_POST['term_id'] ) ); - $taxonomy = \sanitize_text_field( \wp_unslash( $_POST['taxonomy'] ) ); - $description = \wp_kses_post( \wp_unslash( $_POST['description'] ) ); - - // Verify the term exists. - $term = \get_term( $term_id, $taxonomy ); - if ( ! $term || \is_wp_error( $term ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Term not found.', 'progress-planner' ) ] ); - } - - // Update the term description. - $result = \wp_update_term( - $term_id, - $taxonomy, - [ - 'description' => $description, - ] - ); - - if ( \is_wp_error( $result ) ) { - \wp_send_json_error( [ 'message' => $result->get_error_message() ] ); - } - - \wp_send_json_success( [ 'message' => \esc_html__( 'Term description updated successfully.', 'progress-planner' ) ] ); - } -} diff --git a/classes/suggested-tasks/providers/class-user.php b/classes/suggested-tasks/providers/class-user.php deleted file mode 100644 index 91e6b13b8d..0000000000 --- a/classes/suggested-tasks/providers/class-user.php +++ /dev/null @@ -1,131 +0,0 @@ -get_suggested_tasks_db()->get_post( $task_data['task_id'] ); - return $task_post ? $task_post->get_data() : []; - } - - /** - * 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__( 'Edit', 'progress-planner' ) . '', - ]; - - return $actions; - } - - /** - * Modify the task details for user tasks in REST format. - * - * @param array $tasks The tasks. - * @param array $args The arguments. - * - * @return array - */ - public function modify_task_details_for_user_tasks_rest_format( $tasks, $args ) { - static $modified_tasks = []; - // Only process when fetching user tasks (include_provider contains 'user'). - if ( ! isset( $args['include_provider'] ) || ! \in_array( 'user', $args['include_provider'], true ) ) { - return $tasks; - } - - // Loop through all tasks in the flat array. - foreach ( $tasks as $key => $task ) { - // Only process user provider tasks. - if ( ! isset( $task['prpl_provider']->slug ) || $task['prpl_provider']->slug !== self::PROVIDER_ID ) { // @phpstan-ignore-line property.nonObject - continue; - } - - if ( \in_array( $task['id'], $modified_tasks, true ) ) { - continue; - } - - // Set points: 1 for golden task (excerpt contains 'GOLDEN'), 0 for regular user tasks. - $task['prpl_points'] = ( isset( $task['excerpt']['rendered'] ) && \str_contains( $task['excerpt']['rendered'], 'GOLDEN' ) ) ? 1 : 0; - $tasks[ $key ] = $task; - $modified_tasks[] = $task['id']; - } - return $tasks; - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php b/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php deleted file mode 100644 index 8b797d3bd3..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-add-aioseo-providers.php +++ /dev/null @@ -1,52 +0,0 @@ -providers = [ - new Archive_Author(), - new Archive_Date(), - new Media_Pages(), - new Crawl_Settings_Feed_Authors(), - new Crawl_Settings_Feed_Comments(), - new Organization_Logo(), - ]; - - return \array_merge( - $providers, - $this->providers - ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php b/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php deleted file mode 100644 index 884a35ddcc..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-aioseo-interactive-provider.php +++ /dev/null @@ -1,30 +0,0 @@ -is_task_relevant() ) { - return false; - } - - // Check if author archives are already disabled in AIOSEO. - // AIOSEO uses 'show' property - when false, archives are hidden from search results. - // Get a fresh copy of the options to avoid caching issues. - $show_value = \aioseo()->options->searchAppearance->archives->author->show; - - // If show is false (disabled), the task is complete (return false means don't add task). - // Using loose comparison to handle string/int/bool variations. - if ( ! $show_value ) { - return false; - } - - return true; - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If there is more than one author, we don't need to add the task. - return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; - } - - /** - * Get the description. - * - * @return void - */ - public function print_popover_instructions() { - echo '

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

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Noindex the author archive', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - $this->verify_aioseo_active_or_fail(); - $this->verify_nonce_or_fail(); - - \aioseo()->options->searchAppearance->archives->author->show = false; // @phpstan-ignore-line - - // Update the option. - \aioseo()->options->save(); // @phpstan-ignore-line - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php deleted file mode 100644 index 2b60d55fc6..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php +++ /dev/null @@ -1,165 +0,0 @@ -is_task_relevant() ) { - return false; - } - - // Check if date archives are already disabled in AIOSEO. - // AIOSEO uses 'show' property - when false, archives are hidden from search results. - $show_value = \aioseo()->options->searchAppearance->archives->date->show; - - // If show is false (disabled), the task is complete (return false means don't add task). - // Using loose comparison to handle string/int/bool variations. - if ( ! $show_value ) { - return false; - } - - return true; - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If the permalink structure includes %year%, %monthnum%, or %day%, we don't need to add the task. - $permalink_structure = \get_option( 'permalink_structure' ); - return \strpos( $permalink_structure, '%year%' ) === false - && \strpos( $permalink_structure, '%monthnum%' ) === false - && \strpos( $permalink_structure, '%day%' ) === false; - } - - /** - * Get the description. - * - * @return void - */ - public function print_popover_instructions() { - echo '

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

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Noindex the date archive', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - $this->verify_aioseo_active_or_fail(); - $this->verify_nonce_or_fail(); - - \aioseo()->options->searchAppearance->archives->date->show = false; // @phpstan-ignore-line - - // Update the option. - \aioseo()->options->save(); // @phpstan-ignore-line - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php deleted file mode 100644 index 74297316e5..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php +++ /dev/null @@ -1,175 +0,0 @@ -is_task_relevant() ) { - return false; - } - - // Check if crawl cleanup is enabled and author feeds are disabled. - $disable_author_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors; - - // Check if author feeds are already disabled. - if ( $disable_author_feed === false ) { - return false; - } - - return true; - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If there is more than one author, we don't need to add the task. - return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; - } - - /** - * Get the description. - * - * @return void - */ - public function print_popover_instructions() { - echo '

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

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable author RSS feeds', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - $this->verify_aioseo_active_or_fail(); - $this->verify_nonce_or_fail(); - - \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; // @phpstan-ignore-line - - // Update the option. - \aioseo()->options->save(); // @phpstan-ignore-line - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php deleted file mode 100644 index b942ac4a4c..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php +++ /dev/null @@ -1,152 +0,0 @@ -options->searchAppearance->advanced->crawlCleanup->feeds->globalComments; // @phpstan-ignore-line - $disable_post_comment_feed = \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments; // @phpstan-ignore-line - - // Check if comment feeds are already disabled. - if ( $disable_global_comment_feed === false && $disable_post_comment_feed === false ) { - return false; - } - - return true; - } - - /** - * Get the description. - * - * @return void - */ - public function print_popover_instructions() { - echo '

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

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable comment RSS feeds', 'progress-planner' ) ); - } - - /** - * Handle the interactive task submit. - * - * This is only for interactive tasks that change non-core settings. - * The $_POST data is expected to be: - * - nonce: (string) The nonce. - * - * @return void - */ - public function handle_interactive_task_specific_submit() { - $this->verify_aioseo_active_or_fail(); - $this->verify_nonce_or_fail(); - - // Global comment feed. - if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments ) { // @phpstan-ignore-line - \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments = false; // @phpstan-ignore-line - } - - // Post comment feed. - if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments ) { // @phpstan-ignore-line - \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->postComments = false; // @phpstan-ignore-line - } - - // Update the option. - \aioseo()->options->save(); // @phpstan-ignore-line - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php deleted file mode 100644 index 93b22f6461..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php +++ /dev/null @@ -1,151 +0,0 @@ - postTypes -> attachment -> redirectAttachmentUrls. - $redirect = \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls; - - // The task is complete if redirectAttachmentUrls is set to 'attachment'. - // Possible values: 'disabled', 'attachment', or 'attachmentParent'. - // We recommend 'attachment' as it redirects to the attachment file itself. - if ( 'attachment' === $redirect ) { - return false; - } - - return true; - } - - /** - * Get the description. - * - * @return void - */ - public function print_popover_instructions() { - echo '

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

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - ?> - - verify_aioseo_active_or_fail(); - $this->verify_nonce_or_fail(); - - \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; // @phpstan-ignore-line - - // Update the option. - \aioseo()->options->save(); // @phpstan-ignore-line - - \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - return $this->add_popover_action( $actions, \__( 'Redirect', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php deleted file mode 100644 index dfc1b4e41b..0000000000 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-organization-logo.php +++ /dev/null @@ -1,101 +0,0 @@ -get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); - } - - $options = \aioseo()->options->searchAppearance->global->schema; - $is_person = isset( $options->siteRepresents ) && 'person' === $options->siteRepresents; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - return $is_person - ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-person-logo' ) - : \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/aioseo-organization-logo' ); - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - // Check if AIOSEO is active. - if ( ! \function_exists( 'aioseo' ) ) { - return false; - } - - $represents = \aioseo()->options->searchAppearance->global->schema->siteRepresents; - - // Check if logo is already set. - if ( $represents === 'person' ) { - return false; - } - - // Check organization logo. - return \aioseo()->options->searchAppearance->global->schema->organizationLogo === ''; - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php b/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php deleted file mode 100644 index c4a96db060..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-add-yoast-providers.php +++ /dev/null @@ -1,138 +0,0 @@ -providers as $provider ) { - // Add Ravi icon if the task is published or is completed. - if ( $provider->is_task_relevant() || \progress_planner()->get_suggested_tasks()->was_task_completed( $provider->get_task_id() ) ) { - if ( \method_exists( $provider, 'get_focus_tasks' ) ) { - $focus_task = $provider->get_focus_tasks(); - - if ( $focus_task ) { - $focus_tasks = \array_merge( $focus_tasks, $focus_task ); - } - } - } - } - - // Enqueue the script. - \progress_planner()->get_admin__enqueue()->enqueue_script( - 'yoast-focus-element', - [ - 'name' => 'progressPlannerYoastFocusElement', - 'data' => [ - 'tasks' => $focus_tasks, - 'base_url' => \constant( 'PROGRESS_PLANNER_URL' ), - ], - ] - ); - - // Enqueue the style. - \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/focus-element' ); - } - /** - * Add the providers. - * - * @param array $providers The providers. - * @return array - */ - public function add_providers( $providers ) { - $this->providers = [ - new Archive_Author(), - new Archive_Date(), - new Archive_Format(), - new Crawl_Settings_Feed_Global_Comments(), - new Crawl_Settings_Feed_Authors(), - new Crawl_Settings_Emoji_Scripts(), - new Media_Pages(), - new Organization_Logo(), - new Fix_Orphaned_Content(), - ]; - - // Yoast SEO Premium. - if ( \defined( 'WPSEO_PREMIUM_VERSION' ) ) { - $this->providers[] = new Cornerstone_Workout(); - $this->providers[] = new Orphaned_Content_Workout(); - } - - return \array_merge( - $providers, - $this->providers - ); - } - - /** - * Exclude taxonomies which are marked as not indexable in Yoast SEO. - * - * @param array $exclude_taxonomies The taxonomies. - * @return array - */ - public function exclude_not_indexable_taxonomies( $exclude_taxonomies ) { - foreach ( \YoastSEO()->helpers->taxonomy->get_public_taxonomies() as $taxonomy ) { // @phpstan-ignore-line property.nonObject - if ( ! \in_array( $taxonomy, $exclude_taxonomies, true ) - && false === \YoastSEO()->helpers->taxonomy->is_indexable( $taxonomy ) // @phpstan-ignore-line property.nonObject - ) { - $exclude_taxonomies[] = $taxonomy; - } - } - - return $exclude_taxonomies; - } - - /** - * Add the interactive task allowed options. - * - * @param array $allowed_options The allowed options. - * @return array - */ - public function add_interactive_task_allowed_options( $allowed_options ) { - $allowed_options[] = 'wpseo'; - $allowed_options[] = 'wpseo_titles'; - return $allowed_options; - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php deleted file mode 100644 index b4bf9ee3e4..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php +++ /dev/null @@ -1,145 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo_titles-disable-author"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'false', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return $this->is_task_relevant() - && \YoastSEO()->helpers->options->get( 'disable-author' ) !== true; // @phpstan-ignore-line property.nonObject - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If there is more than one author, we don't need to add the task. - return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'When your site has only one author, the author archive is redundant and creates duplicate content issues. Disabling it prevents search engines from indexing the same content multiple times.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php deleted file mode 100644 index 0c2b5f2dd0..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php +++ /dev/null @@ -1,133 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo_titles-disable-date"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'false', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - // If the date archive is already disabled, we don't need to add the task. - return $this->is_task_relevant() && \YoastSEO()->helpers->options->get( 'disable-date' ) !== true; // @phpstan-ignore-line property.nonObject - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If the permalink structure includes %year%, %monthnum%, or %day%, we don't need to add the task. - $permalink_structure = \get_option( 'permalink_structure' ); - return \strpos( $permalink_structure, '%year%' ) === false - && \strpos( $permalink_structure, '%monthnum%' ) === false - && \strpos( $permalink_structure, '%day%' ) === false; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'Date archives are primarily useful for news sites and time-sensitive content. For most websites, they add unnecessary URLs that can dilute your SEO. Disable them unless your content is date-specific.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php deleted file mode 100644 index f7aad926d1..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php +++ /dev/null @@ -1,145 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo_titles-disable-post_format"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'false', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return $this->is_task_relevant() - && \YoastSEO()->helpers->options->get( 'disable-post_format' ) !== true; // @phpstan-ignore-line property.nonObject - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If there are more than X posts with a post format, we don't need to add the task. X is set in the class. - return $this->get_data_collector()->collect() <= static::MINIMUM_POSTS_WITH_FORMAT; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress creates separate archives for each post format (standard, aside, gallery, etc.). Most sites don\'t use post formats, making these archives unnecessary. Disabling them reduces crawl waste and potential duplicate content.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php b/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php deleted file mode 100644 index 729be9be90..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-cornerstone-workout.php +++ /dev/null @@ -1,165 +0,0 @@ -init_dismissable_task(); - - // Hook into update_option. - \add_action( 'update_option_wpseo_premium', [ $this, 'maybe_update_workout_status' ], 10, 3 ); - } - - /** - * Maybe update the workout status. - * - * @param mixed $old_value The old value. - * @param mixed $value The new value. - * @param string $option The option name. - * - * @return void - */ - public function maybe_update_workout_status( $old_value, $value, $option ) { - if ( 'wpseo_premium' !== $option || ! isset( $value['workouts']['cornerstone'] ) || ! isset( $old_value['workouts']['cornerstone'] ) ) { - return; - } - - // Check if there is a published task. - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $this->get_task_id() ] ); - - // If there is no published task, return. - if ( empty( $tasks ) || 'publish' !== $tasks[0]->post_status ) { - return; - } - - // For this type of task only the provider ID is needed, but just in case. - if ( $this->is_task_dismissed( $tasks[0]->get_data() ) ) { - return; - } - - // There should be 3 steps in the workout. - $workout_was_completed = 3 === \count( $old_value['workouts']['cornerstone']['finishedSteps'] ); - $workout_completed = 3 === \count( $value['workouts']['cornerstone']['finishedSteps'] ); - - // Dismiss the task if workout wasn't completed before and now is. - if ( ! $workout_was_completed && $workout_completed ) { - $this->handle_task_dismissal( $this->get_task_id() ); - } - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Yoast SEO: do Yoast SEO\'s Cornerstone Content Workout', 'progress-planner' ); - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return \esc_url( \admin_url( 'admin.php?page=wpseo_workouts#cornerstone' ) ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return \defined( 'WPSEO_PREMIUM_VERSION' ) - && ! $this->is_task_dismissed( - [ - 'provider_id' => $this->get_provider_id(), - ] - ); - } - - /** - * 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__( 'Run workout', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php deleted file mode 100644 index 72abfc4358..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php +++ /dev/null @@ -1,125 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo-remove_emoji_scripts"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'true', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $yoast_options = \WPSEO_Options::get_instance()->get_all(); - foreach ( [ 'remove_emoji_scripts' ] as $option ) { - // If the crawl settings are already optimized, we don't need to add the task. - if ( $yoast_options[ $option ] ) { - return false; - } - } - - return true; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress loads extra JavaScript to support emojis in older browsers. Modern browsers (and most of your visitors) don\'t need this, so removing it improves your site\'s loading speed.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Remove', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php deleted file mode 100644 index d68d98caf8..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php +++ /dev/null @@ -1,156 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo-remove_feed_authors"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'true', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - if ( ! $this->is_task_relevant() ) { - return false; - } - - $yoast_options = \WPSEO_Options::get_instance()->get_all(); - foreach ( [ 'remove_feed_authors' ] as $option ) { - // If the crawl settings are already optimized, we don't need to add the task. - if ( $yoast_options[ $option ] ) { - return false; - } - } - - return true; - } - - /** - * Check if the task is still relevant. - * For example, we have a task to disable author archives if there is only one author. - * If in the meantime more authors are added, the task is no longer relevant and the task should be removed. - * - * @return bool - */ - public function is_task_relevant() { - // If there is more than one author, we don't need to add the task. - return $this->get_data_collector()->collect() <= self::MINIMUM_AUTHOR_WITH_POSTS; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress creates RSS feeds for each author (e.g., /author/john/feed/). Unless you\'re running a multi-author blog where readers want to follow specific writers, these feeds are unnecessary and create extra URLs for search engines to crawl.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Remove', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php deleted file mode 100644 index c28b88230c..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php +++ /dev/null @@ -1,125 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo-remove_feed_global_comments"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'true', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - $yoast_options = \WPSEO_Options::get_instance()->get_all(); - foreach ( [ 'remove_feed_global_comments' ] as $option ) { - // If the crawl settings are already optimized, we don't need to add the task. - if ( $yoast_options[ $option ] ) { - return false; - } - } - - return true; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress creates an RSS feed of all comments on your site. Unless you have active discussions that people want to follow, this feed is rarely used and creates an unnecessary URL.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Remove', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php b/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php deleted file mode 100644 index cdaa762268..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-fix-orphaned-content.php +++ /dev/null @@ -1,240 +0,0 @@ -get_ui__branding()->get_url( 'https://prpl.fyi/fix-orphaned-content' ) - : ''; - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return ! empty( $this->get_data_collector()->collect() ); - } - - /** - * Check if a specific task is completed. - * Child classes can override this method to handle specific task IDs. - * - * @param string $task_id The task ID to check. - * - * @return bool - */ - protected function is_specific_task_completed( $task_id ) { - $post = $this->get_post_from_task_id( $task_id ); - - // Post was deleted. - if ( ! $post ) { - return true; - } - - global $wpdb; - - $linked_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $wpdb->prepare( - "SELECT COUNT(*) FROM {$wpdb->prefix}yoast_seo_links WHERE target_post_id = %d AND type = 'internal'", // @phpstan-ignore-line property.nonObject - $post->ID - ) - ); - - return 0 !== (int) $linked_count; - } - - /** - * Get an array of tasks to inject. - * - * @return array - */ - public function get_tasks_to_inject() { - if ( true === $this->is_task_snoozed() || ! $this->should_add_task() ) { - return []; - } - - $data = $this->transform_collector_data( $this->get_data_collector()->collect() ); - $task_id = $this->get_task_id( [ 'target_post_id' => $data['target_post_id'] ] ); - - // When we have data, check if task was completed. - if ( true === \progress_planner()->get_suggested_tasks()->was_task_completed( $task_id ) ) { - return []; - } - - // Transform the data to match the task data structure. - $task_data = $this->modify_injection_task_data( - $this->get_task_details( - $data - ) - ); - - return \progress_planner()->get_suggested_tasks_db()->get_post( $task_data['task_id'] ) - ? [] - : [ \progress_planner()->get_suggested_tasks_db()->add( $task_data ) ]; - } - - /** - * Modify task data before injecting it. - * - * @param array $task_data The task data. - * - * @return array - */ - protected function modify_injection_task_data( $task_data ) { - $task_data['target_post_id'] = $this->transform_collector_data( $this->get_data_collector()->collect() )['target_post_id']; - return $task_data; - } - - /** - * Get the post ID from the task ID. - * - * @param string $task_id The task ID. - * - * @return \WP_Post|null - */ - public function get_post_from_task_id( $task_id ) { - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] ); - - if ( empty( $tasks ) ) { - return null; - } - - return $tasks[0]->target_post_id ? \get_post( $tasks[0]->target_post_id ) : null; - } - - /** - * Get the dismissed post IDs. - * - * @return array - */ - protected function get_completed_post_ids() { - if ( ! empty( $this->completed_post_ids ) ) { - return $this->completed_post_ids; - } - - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => $this->get_provider_id() ] ); - - foreach ( $tasks as $task ) { - if ( 'trash' === $task->post_status ) { - $this->completed_post_ids[] = $task->target_post_id; - } - } - - return $this->completed_post_ids; - } - - /** - * Exclude completed posts. - * - * @param array $exclude_post_ids The excluded post IDs. - * @return array - */ - public function exclude_completed_posts( $exclude_post_ids ) { - return \array_merge( $exclude_post_ids, $this->get_completed_post_ids() ); - } - - /** - * 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__( 'Learn more about internal linking', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php deleted file mode 100644 index 64e4387c46..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php +++ /dev/null @@ -1,119 +0,0 @@ - '.yst-toggle-field__header', - 'valueElement' => [ - 'elementSelector' => 'button[data-id="input-wpseo_titles-disable-attachment"]', - 'attributeName' => 'aria-checked', - 'attributeValue' => 'false', - 'operator' => '=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - // If the media pages are already disabled, we don't need to add the task. - return \YoastSEO()->helpers->options->get( 'disable-attachment' ) !== true; // @phpstan-ignore-line property.nonObject - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - \esc_html_e( 'WordPress creates individual pages for every image you upload. These attachment pages rarely provide value and can cause thin content issues for SEO. Disable them unless you\'re running a photography or art portfolio site where the attachment pages themselves are important.', 'progress-planner' ); - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - $this->print_submit_button( \__( 'Disable', '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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php deleted file mode 100644 index 899aab77f6..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php +++ /dev/null @@ -1,237 +0,0 @@ -yoast_seo = \YoastSEO(); - } - - /** - * Check if the site is in person mode. - * - * @return bool - */ - protected function is_person_mode() { - return 'person' === $this->yoast_seo->helpers->options->get( 'company_or_person', 'company' ); // @phpstan-ignore-line property.nonObject - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return \admin_url( 'admin.php?page=wpseo_page_settings#/site-representation' ); - } - - /** - * Get the title. - * - * @return string - */ - protected function get_title() { - return $this->is_person_mode() - ? \esc_html__( 'Yoast SEO: set your person logo', 'progress-planner' ) - : \esc_html__( 'Yoast SEO: set your organization logo', 'progress-planner' ); - } - - /** - * Get external link URL. - * - * @return string - */ - public function get_external_link_url() { - return $this->is_person_mode() - ? \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/yoast-organization-logo' ) - : \progress_planner()->get_ui__branding()->get_url( 'https://prpl.fyi/yoast-person-logo' ); - } - - /** - * Get the focus tasks. - * - * @return array - */ - public function get_focus_tasks() { - return [ - [ - 'iconElement' => 'legend.yst-label', - 'valueElement' => [ - 'elementSelector' => 'input[name="wpseo_titles.company_logo"]', - 'attributeName' => 'value', - 'attributeValue' => '', - 'operator' => '!=', - ], - ], - [ - 'iconElement' => 'legend.yst-label', - 'valueElement' => [ - 'elementSelector' => 'input[name="wpseo_titles.person_logo"]', - 'attributeName' => 'value', - 'attributeValue' => '', - 'operator' => '!=', - ], - ], - ]; - } - - /** - * Determine if the task should be added. - * - * @return bool - */ - public function should_add_task() { - - // Check if the site logo is set, Yoast SEO uses it as a fallback. - $site_logo_id = \get_option( 'site_logo' ); - if ( ! $site_logo_id ) { - $site_logo_id = \get_theme_mod( 'custom_logo', false ); - } - - // If the site logo is set, we don't need to add the task. - if ( (int) $site_logo_id ) { - return false; - } - - // If the site is for a company, and the company logo is already set, we don't need to add the task. - if ( ! $this->is_person_mode() - && $this->yoast_seo->helpers->options->get( 'company_logo' ) // @phpstan-ignore-line property.nonObject - ) { - return false; - } - - // If the site is for a person, and the person logo is already set, we don't need to add the task. - if ( $this->is_person_mode() - && $this->yoast_seo->helpers->options->get( 'person_logo' ) // @phpstan-ignore-line property.nonObject - ) { - return false; - } - - return true; - } - - /** - * Get the popover instructions. - * - * @return void - */ - public function print_popover_instructions() { - echo '

    '; - if ( $this->is_person_mode() ) { - \printf( - /* translators: %s: "Read more" link. */ - \esc_html__( 'To make Yoast SEO output the correct Schema, you need to set your person logo in the Yoast SEO settings. %s.', 'progress-planner' ), - '' . \esc_html__( 'Read more', 'progress-planner' ) . '' - ); - } else { - \printf( - /* translators: %s: "Read more" link. */ - \esc_html__( 'To make Yoast SEO output the correct Schema, you need to set your organization logo in the Yoast SEO settings. %s.', 'progress-planner' ), - '' . \esc_html__( 'Read more', 'progress-planner' ) . '' - ); - } - echo '

    '; - } - - /** - * Print the popover input field for the form. - * - * @return void - */ - public function print_popover_form_contents() { - // Enqueue media scripts. - \wp_enqueue_media(); - - $organization_logo_id = $this->is_person_mode() - ? $this->yoast_seo->helpers->options->get( 'person_logo' ) // @phpstan-ignore-line property.nonObject - : $this->yoast_seo->helpers->options->get( 'company_logo' ); // @phpstan-ignore-line property.nonObject - ?> -
    - - 'max-width: 150px; height: auto; border-radius: 4px; border: 1px solid #ddd;' ] ); ?> - - - -
    - - -
    - -
    - 'prplYoastOrganizationLogo', - 'data' => [ - 'mediaTitle' => $this->is_person_mode() - ? \esc_html__( 'Choose Person Logo', 'progress-planner' ) - : \esc_html__( 'Choose Organization Logo', 'progress-planner' ), - 'mediaButtonText' => $this->is_person_mode() - ? \esc_html__( 'Use as Person Logo', 'progress-planner' ) - : \esc_html__( 'Use as Organization Logo', 'progress-planner' ), - 'companyOrPerson' => $this->is_person_mode() ? 'person' : 'company', - ], - ]; - } - - /** - * 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 = [] ) { - return $this->add_popover_action( $actions, \__( 'Set logo', 'progress-planner' ) ); - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php b/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php deleted file mode 100644 index df5eeb3f0d..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-orphaned-content-workout.php +++ /dev/null @@ -1,164 +0,0 @@ -init_dismissable_task(); - - // Hook into update_option. - \add_action( 'update_option_wpseo_premium', [ $this, 'maybe_update_workout_status' ], 10, 3 ); - } - - /** - * Maybe update the workout status. - * - * @param mixed $old_value The old value. - * @param mixed $value The new value. - * @param string $option The option name. - * - * @return void - */ - public function maybe_update_workout_status( $old_value, $value, $option ) { - if ( 'wpseo_premium' !== $option - || ! isset( $value['workouts']['orphaned'] ) - || ! isset( $old_value['workouts']['orphaned'] ) - ) { - return; - } - - // Check if there is a published task. - $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $this->get_task_id() ] ); - - // If there is no published task, return. - if ( empty( $tasks ) || 'publish' !== $tasks[0]->post_status ) { - return; - } - - // For this type of task only the provider ID is needed, but just in case. - if ( $this->is_task_dismissed( $tasks[0]->get_data() ) ) { - return; - } - - // There should be 3 steps in the workout. - $workout_was_completed = 3 === \count( $old_value['workouts']['orphaned']['finishedSteps'] ); - $workout_completed = 3 === \count( $value['workouts']['orphaned']['finishedSteps'] ); - - // Dismiss the task if workout wasn't completed before and now is. - if ( ! $workout_was_completed && $workout_completed ) { - $this->handle_task_dismissal( $this->get_task_id() ); - } - } - - /** - * Get the task title. - * - * @return string - */ - protected function get_title() { - return \esc_html__( 'Yoast SEO: do Yoast SEO\'s Orphaned Content Workout', 'progress-planner' ); - } - - /** - * Get the task URL. - * - * @return string - */ - protected function get_url() { - return \esc_url( \admin_url( 'admin.php?page=wpseo_workouts#orphaned' ) ); - } - - /** - * Check if the task should be added. - * - * @return bool - */ - public function should_add_task() { - return \defined( 'WPSEO_PREMIUM_VERSION' ) - && ! $this->is_task_dismissed( [ 'provider_id' => $this->get_provider_id() ] ); - } - - /** - * 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__( 'Run workout', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php b/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php deleted file mode 100644 index a2d2b0ea71..0000000000 --- a/classes/suggested-tasks/providers/integrations/yoast/class-yoast-interactive-provider.php +++ /dev/null @@ -1,39 +0,0 @@ - \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } - } - - /** - * Perform complete AIOSEO AJAX security checks. - * - * Runs AIOSEO active check, capability check, and nonce verification. - * This is a convenience method for AIOSEO interactive tasks. - * - * @param string $capability The capability to require (default: 'manage_options'). - * @param string $action The nonce action to verify (default: 'progress_planner'). - * @param string $field The POST field containing the nonce (default: 'nonce'). - * - * @return void Exits with wp_send_json_error() if any check fails. - */ - protected function verify_aioseo_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { - $this->verify_aioseo_active_or_fail(); - $this->verify_ajax_security( $capability, $action, $field ); - } -} diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-base.php b/classes/suggested-tasks/providers/traits/class-ajax-security-base.php deleted file mode 100644 index 6526022904..0000000000 --- a/classes/suggested-tasks/providers/traits/class-ajax-security-base.php +++ /dev/null @@ -1,72 +0,0 @@ - \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - } - - /** - * Verify user capabilities or send JSON error and exit. - * - * Checks if the current user has the specified capability and terminates - * execution with a JSON error response if they don't. - * - * @param string $capability The capability to check (default: 'manage_options'). - * - * @return void Exits with wp_send_json_error() if user lacks capability. - */ - protected function verify_capability_or_fail( $capability = 'manage_options' ) { - if ( ! \current_user_can( $capability ) ) { - \wp_send_json_error( - [ 'message' => \esc_html__( 'You do not have permission to perform this action.', 'progress-planner' ) ] - ); - } - } - - /** - * Perform all standard AJAX security checks. - * - * Runs nonce verification and capability check in one call. - * Useful for most AJAX handlers that require both checks. - * - * @param string $capability The capability to require (default: 'manage_options'). - * @param string $action The nonce action to verify (default: 'progress_planner'). - * @param string $field The POST field containing the nonce (default: 'nonce'). - * - * @return void Exits with wp_send_json_error() if any check fails. - */ - protected function verify_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { - $this->verify_capability_or_fail( $capability ); - $this->verify_nonce_or_fail( $action, $field ); - } -} diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php b/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php deleted file mode 100644 index 2f7714a06c..0000000000 --- a/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php +++ /dev/null @@ -1,55 +0,0 @@ - \esc_html__( 'Yoast SEO is not active.', 'progress-planner' ) ] ); - } - } - - /** - * Perform complete Yoast SEO AJAX security checks. - * - * Runs Yoast active check, capability check, and nonce verification. - * This is a convenience method for Yoast interactive tasks. - * - * @param string $capability The capability to require (default: 'manage_options'). - * @param string $action The nonce action to verify (default: 'progress_planner'). - * @param string $field The POST field containing the nonce (default: 'nonce'). - * - * @return void Exits with wp_send_json_error() if any check fails. - */ - protected function verify_yoast_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { - $this->verify_yoast_active_or_fail(); - $this->verify_ajax_security( $capability, $action, $field ); - } -} diff --git a/classes/suggested-tasks/providers/traits/class-dismissable-task.php b/classes/suggested-tasks/providers/traits/class-dismissable-task.php deleted file mode 100644 index 5c068538d8..0000000000 --- a/classes/suggested-tasks/providers/traits/class-dismissable-task.php +++ /dev/null @@ -1,247 +0,0 @@ -get_suggested_tasks_db()->get_post( $post_id ); - - // If no task data is found, return. - if ( ! $task ) { - return; - } - - // If the task provider ID does not match, return. - if ( ! isset( $task->provider->slug ) || $this->get_provider_id() !== $task->provider->slug ) { - return; - } - - // Get the dismissed tasks. - $dismissed_tasks = \progress_planner()->get_settings()->get( $this->dismissed_tasks_option, [] ); - - // Get the provider key. - $provider_id = $this->get_provider_id(); - - // If the provider key does not exist, create it. - if ( ! isset( $dismissed_tasks[ $provider_id ] ) ) { - $dismissed_tasks[ $provider_id ] = []; - } - - // Get the task identifier. - $task_identifier = $this->get_task_identifier( $task->get_data() ); - - // If no task identifier is found, return. - if ( ! $task_identifier ) { - return; - } - - // Store the task dismissal data. - $dismissal_data = [ - 'date' => \gmdate( 'oW' ), - 'timestamp' => \time(), - ]; - - /** - * Filter the task dismissal data before it's stored. - * - * @param array $dismissal_data The dismissal data. - * @param array $task_data The task data. - * @param string $provider_id The provider ID. - */ - $dismissal_data = \apply_filters( 'progress_planner_task_dismissal_data', $dismissal_data, $task->get_data(), $provider_id ); - - $dismissed_tasks[ $provider_id ][ $task_identifier ] = $dismissal_data; - - // Store the dismissed tasks. - \progress_planner()->get_settings()->set( $this->dismissed_tasks_option, $dismissed_tasks ); - } - - /** - * Get the task identifier for storing dismissal data. - * Override this method in the implementing class to provide task-specific identification. - * - * @param array $task_data The task data. - * - * @return string|false The task identifier or false if not applicable. - */ - protected function get_task_identifier( $task_data ) { - $task_identifier = $this->get_provider_id(); - $task_identifier .= isset( $task_data['target_post_id'] ) ? '-' . $task_data['target_post_id'] : ''; - $task_identifier .= isset( $task_data['target_term_id'] ) ? '-' . $task_data['target_term_id'] : ''; - - return $task_identifier; - } - - /** - * Get the expiration period in seconds. - * Override this method in the implementing class to provide task-specific expiration period. - * - * @param array $dismissal_data The dismissal data. - * - * @return int The expiration period in seconds. - */ - protected function get_expiration_period( $dismissal_data = [] ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - return 6 * MONTH_IN_SECONDS; - } - - /** - * Check if a task has been dismissed. - * - * @param array $task_data The task data to check. - * - * @return bool - */ - protected function is_task_dismissed( $task_data ) { - $dismissed_tasks = \progress_planner()->get_settings()->get( $this->dismissed_tasks_option, [] ); - $provider_key = $this->get_provider_id(); - - if ( ! isset( $dismissed_tasks[ $provider_key ] ) ) { - return false; - } - - $task_identifier = $this->get_task_identifier( $task_data ); - if ( ! $task_identifier || ! isset( $dismissed_tasks[ $provider_key ][ $task_identifier ] ) ) { - return false; - } - - $dismissal_data = $dismissed_tasks[ $provider_key ][ $task_identifier ]; - - // If the task was dismissed in the current week, don't show it again. - if ( $dismissal_data['date'] === \gmdate( 'oW' ) ) { - return true; - } - - // If the task was dismissed more than the expiration period ago, we can show it again. - if ( ( \time() - $dismissal_data['timestamp'] ) > $this->get_expiration_period( $dismissal_data ) ) { - unset( $dismissed_tasks[ $provider_key ][ $task_identifier ] ); - \progress_planner()->get_settings()->set( $this->dismissed_tasks_option, $dismissed_tasks ); - return false; - } - - return true; - } - - /** - * Get the provider dismissed tasks. - * - * @return array - */ - public function get_dismissed_tasks() { - return \progress_planner()->get_settings()->get( $this->dismissed_tasks_option, [] )[ $this->get_provider_id() ] ?? []; - } - - /** - * Clean up old dismissals for this provider. - * - * @return void - */ - public function cleanup_old_dismissals() { - if ( \progress_planner()->get_utils__cache()->get( 'cleanup_dismissed_tasks' ) ) { - return; - } - - $dismissed_tasks = \progress_planner()->get_settings()->get( $this->dismissed_tasks_option, [] ); - $provider_key = $this->get_provider_id(); - - if ( ! isset( $dismissed_tasks[ $provider_key ] ) ) { - return; - } - - $has_changes = false; - foreach ( $dismissed_tasks[ $provider_key ] as $identifier => $data ) { - if ( ( \time() - $data['timestamp'] ) > $this->get_expiration_period( $data ) ) { - unset( $dismissed_tasks[ $provider_key ][ $identifier ] ); - $has_changes = true; - } - } - - if ( $has_changes ) { - \progress_planner()->get_settings()->set( $this->dismissed_tasks_option, $dismissed_tasks ); - } - - // Set transient to prevent running cleanup again today. - \progress_planner()->get_utils__cache()->set( 'cleanup_dismissed_tasks', true, DAY_IN_SECONDS ); - } - - /** - * Add post ID to dismissal data. - * - * @param array $dismissal_data The dismissal data. - * @param array $task_data The task data. - * @param string $provider_id The provider ID. - * - * @return array - */ - public function add_post_id_to_dismissal_data( $dismissal_data, $task_data, $provider_id ) { - if ( $this->get_provider_id() === $provider_id && isset( $task_data['target_post_id'] ) ) { - $dismissal_data['post_id'] = $task_data['target_post_id']; - } - return $dismissal_data; - } - - /** - * Add term ID to dismissal data. - * - * @param array $dismissal_data The dismissal data. - * @param array $task_data The task data. - * @param string $provider_id The provider ID. - * - * @return array - */ - public function add_term_id_to_dismissal_data( $dismissal_data, $task_data, $provider_id ) { - if ( $this->get_provider_id() === $provider_id && isset( $task_data['target_term_id'] ) ) { - $dismissal_data['term_id'] = $task_data['target_term_id']; - } - return $dismissal_data; - } -} diff --git a/classes/suggested-tasks/providers/traits/class-task-action-builder.php b/classes/suggested-tasks/providers/traits/class-task-action-builder.php deleted file mode 100644 index 2649d24c07..0000000000 --- a/classes/suggested-tasks/providers/traits/class-task-action-builder.php +++ /dev/null @@ -1,68 +0,0 @@ - $priority, - 'html' => $this->generate_popover_button_html( $label ), - ]; - } - - /** - * Generate the HTML for a popover trigger button. - * - * @param string $label The text to display for the action link. - * - * @return string The HTML for the popover trigger button. - */ - protected function generate_popover_button_html( $label ) { - return \sprintf( - '%2$s', - \esc_attr( static::POPOVER_ID ), - \esc_html( $label ) - ); - } - - /** - * Add a popover action to the actions array. - * - * Convenience method that adds a popover action and returns the modified array. - * - * @param array $actions The existing actions array. - * @param string $label The text to display for the action link. - * @param int $priority The priority of the action (default: 10). - * - * @return array The modified actions array. - */ - protected function add_popover_action( $actions, $label, $priority = 10 ) { - $actions[] = $this->create_popover_action( $label, $priority ); - return $actions; - } -} diff --git a/classes/ui/class-branding.php b/classes/ui/class-branding.php index aea8576d01..4e2af67f7e 100644 --- a/classes/ui/class-branding.php +++ b/classes/ui/class-branding.php @@ -21,13 +21,6 @@ final class Branding { 'default' => 0, ]; - /** - * Constructor. - */ - public function __construct() { - \add_filter( 'progress_planner_admin_widgets', [ $this, 'filter_widgets' ] ); - } - /** * Get the branding ID. * @@ -239,25 +232,6 @@ public function get_remote_data( $url ) { return $body; } - /** - * Filter the widgets to be displayed on the admin page. - * - * @param array<\Progress_Planner\Admin\Widgets\Widget> $widgets The widgets. - * - * @return array<\Progress_Planner\Admin\Widgets\Widget> - */ - public function filter_widgets( $widgets ) { - if ( empty( $this->get_api_data() ) || ! isset( $this->get_api_data()['papers'] ) ) { - return $widgets; - } - - $show_papers = $this->get_api_data()['papers']; - if ( ! $show_papers ) { - return $widgets; - } - - return \array_filter( $widgets, fn( $widget ) => \in_array( $widget->get_id(), $show_papers, true ) ); - } /** * Get the widget title. diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php index 587bd452ee..e45a389fe8 100644 --- a/classes/ui/class-chart.php +++ b/classes/ui/class-chart.php @@ -12,22 +12,6 @@ */ class Chart { - /** - * Build a chart for the stats. - * - * @param array $args { - * The arguments for the chart. See `get_chart_data` for all available parameters. - * - * @type string $type Chart type (e.g., 'line', 'bar'). - * } - * - * @return void - */ - public function the_chart( $args = [] ) { - // Render the chart. - $this->render_chart( $args['type'], $this->get_chart_data( $args ) ); - } - /** * Get data for the chart. * @@ -202,17 +186,4 @@ public function get_period_data( $period, $args, $previous_period_activities ) { 'previous_period_activities' => $previous_period_activities, ]; } - - /** - * Render the charts. - * - * @param string $type The type of chart. - * @param array $data The data for the chart. - * - * @return void - */ - public function render_chart( $type, $data ) { - $type = $type ? $type : 'line'; - echo ''; - } } diff --git a/classes/ui/class-popover.php b/classes/ui/class-popover.php deleted file mode 100644 index a375d8d834..0000000000 --- a/classes/ui/class-popover.php +++ /dev/null @@ -1,61 +0,0 @@ -id = $id; - return $popover; - } - - /** - * Render the triggering button. - * - * @param string $icon The dashicon to use. - * @param string $content The content to use. - * @return void - */ - public function render_button( $icon, $content ) { - \progress_planner()->the_view( - 'popovers/parts/icon.php', - [ - 'prpl_popover_id' => $this->id, - 'prpl_popover_trigger_icon' => $icon, - 'prpl_popover_trigger_content' => $content, - ] - ); - } - - /** - * Render the widget content. - * - * @return void - */ - public function render() { - \progress_planner()->the_view( 'popovers/popover.php', [ 'prpl_popover_id' => $this->id ] ); - } -} diff --git a/classes/update/class-update-130.php b/classes/update/class-update-130.php index a621af36e5..637d37e82a 100644 --- a/classes/update/class-update-130.php +++ b/classes/update/class-update-130.php @@ -67,6 +67,35 @@ private function migrate_badges() { \update_option( \Progress_Planner\Settings::OPTION_NAME, $options ); } + /** + * Provider IDs that have been migrated to React and should not be restored. + * + * @var array + */ + private const REACT_MIGRATED_PROVIDERS = [ + 'wp-debug-display', + 'php-version', + 'search-engine-visibility', + 'update-core', + 'create-post', + 'rename-uncategorized-category', + 'core-permalink-structure', + 'core-blogdescription', + 'core-siteicon', + 'disable-comments', + 'fewer-tags', + 'hello-world', + 'sample-page', + 'sending-email', + 'set-page-about', + 'set-page-contact', + 'set-page-faq', + 'set-valuable-post-types', + 'core-timezone', + 'core-locale', + 'core-date-format', + ]; + /** * Restore the completed tasks. * @@ -111,6 +140,11 @@ private function restore_completed_tasks() { continue; } + // Don't import back tasks whose providers have been migrated to React. + if ( \in_array( $data['provider_id'], self::REACT_MIGRATED_PROVIDERS, true ) ) { + continue; + } + // Add the status to the data. $data['status'] = 'completed'; @@ -182,9 +216,6 @@ private function handle_legacy_post_tasks( $task_object ) { * @return Task The task object. */ private function handle_legacy_review_post_tasks( $task_object ) { - // Review provider. - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( 'review-post' ); - // Get the post ID and date from the task ID. $parts = \explode( '-', $task_object->get_task_id() ); @@ -192,7 +223,7 @@ private function handle_legacy_review_post_tasks( $task_object ) { 'task_id' => $task_object->get_task_id(), 'post_id' => $parts[2], 'date' => $parts[3], - 'provider_id' => $task_provider ? $task_provider->get_provider_id() : 'review-post', + 'provider_id' => 'review-post', ]; $task_object->set_data( $data ); diff --git a/classes/update/class-update-161.php b/classes/update/class-update-161.php index 5469e4571a..f630865a1f 100644 --- a/classes/update/class-update-161.php +++ b/classes/update/class-update-161.php @@ -85,75 +85,23 @@ private function migrate_task( $task ) { return; } - // Skip suggested tasks which are not completed or snoozed (but all user tasks are migrated). - if ( 'snoozed' !== $task['status'] && 'completed' !== $task['status'] && 'user' !== $task['provider_id'] ) { + // Only migrate user tasks. Other tasks are now handled by React. + if ( 'user' !== $task['provider_id'] ) { return; } - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task['provider_id'] ); - - // Skip tasks which don't have a task provider. - if ( ! $task_provider ) { - return; - } - - // Now when we have target data - get the task details from the task provider, title, description, url, points, etc. - if ( 'user' === $task['provider_id'] ) { - // User tasks have different data structure, so we can copy directly. - $task_details = [ - 'post_title' => isset( $task['title'] ) ? $task['title'] : '', - 'description' => '', - 'points' => isset( $task['points'] ) ? $task['points'] : 0, - 'provider_id' => 'user', - 'category' => 'user', - 'task_id' => isset( $task['task_id'] ) ? $task['task_id'] : '', - 'post_status' => 'pending' === $task['status'] ? 'publish' : $task['status'], - 'dismissable' => true, - 'snoozable' => false, - ]; - } else { - // Migrate the legacy task data, if the key exists. - // To avoid conflicts and confusion we have added 'target_' prefix to the keys. - $keys_to_migrate = [ - 'post_id', - 'post_title', - 'post_type', - 'term_id', - 'taxonomy', - 'term_name', - ]; - - // Data which is used to build task title, description, url. - $target_data = []; - - foreach ( $keys_to_migrate as $key ) { - if ( isset( $task[ $key ] ) ) { - $target_data[ 'target_' . $key ] = $task[ $key ]; - } - } - - $task_details = $task_provider->get_task_details( $target_data ); - - // Usually repeating tasks have a date. - if ( isset( $task['date'] ) ) { - $task_details['date'] = $task['date']; - } else { - // If not remove it, since get_task_details() method adds a date with \gmdate( 'YW' ) (which will be the date of the migration). - unset( $task_details['date'] ); - } - - // Snoozed tasks have a time. - if ( isset( $task['time'] ) ) { - // Checking if task was snoozed forever (PHP_INT_MAX). - $task_details['time'] = \is_float( $task['time'] ) ? \strtotime( '+10 years' ) : $task['time']; - } - - // Add target data to the task details, we need them in the details as well. - $task_details = \array_merge( $task_details, $target_data ); - - // Add status to the task details. - $task_details['post_status'] = $task['status']; - } + // User tasks have different data structure, so we can copy directly. + $task_details = [ + 'post_title' => isset( $task['title'] ) ? $task['title'] : '', + 'description' => '', + 'points' => isset( $task['points'] ) ? $task['points'] : 0, + 'provider_id' => 'user', + 'category' => 'user', + 'task_id' => isset( $task['task_id'] ) ? $task['task_id'] : '', + 'post_status' => 'pending' === $task['status'] ? 'publish' : $task['status'], + 'dismissable' => true, + 'snoozable' => false, + ]; // Add the task to the database. \progress_planner()->get_suggested_tasks_db()->add( $task_details ); diff --git a/classes/update/class-update-190.php b/classes/update/class-update-190.php index 92d2c0d51e..d999a35ca2 100644 --- a/classes/update/class-update-190.php +++ b/classes/update/class-update-190.php @@ -202,7 +202,6 @@ public function migrate_task_priorities() { 'aioseo-date-archive' => 20, 'php-version' => 25, 'fewer-tags' => 32, - 'collaborator' => 50, 'user' => 50, 'create-post' => 50, 'unpublished-content' => 55, diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php index 6b42a5ca69..c1836b0ff9 100644 --- a/classes/utils/class-debug-tools.php +++ b/classes/utils/class-debug-tools.php @@ -193,16 +193,13 @@ protected function add_upgrading_tasks_submenu_item( $admin_bar ) { $onboard_task_provider_ids = \apply_filters( 'prpl_onboarding_task_providers', [] ); foreach ( $onboard_task_provider_ids as $task_provider_id ) { - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_provider_id ); // @phpstan-ignore-line method.nonObject - if ( $task_provider ) { // @phpstan-ignore-line - $admin_bar->add_node( - [ - 'id' => 'prpl-upgrading-task-' . $task_provider_id, - 'parent' => 'prpl-upgrading-tasks', - 'title' => $task_provider_id, - ] - ); - } + $admin_bar->add_node( + [ + 'id' => 'prpl-upgrading-task-' . $task_provider_id, + 'parent' => 'prpl-upgrading-tasks', + 'title' => $task_provider_id, + ] + ); } } @@ -746,19 +743,6 @@ protected function add_onboarding_submenu_item( $admin_bar ) { ] ); - // 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( [ diff --git a/classes/utils/class-deprecations.php b/classes/utils/class-deprecations.php index 656ce4dc29..1988c2fb59 100644 --- a/classes/utils/class-deprecations.php +++ b/classes/utils/class-deprecations.php @@ -27,13 +27,6 @@ class Deprecations { 'Progress_Planner\Query' => [ 'Progress_Planner\Activities\Query', '1.1.1' ], 'Progress_Planner\Date' => [ 'Progress_Planner\Utils\Date', '1.1.1' ], 'Progress_Planner\Cache' => [ 'Progress_Planner\Utils\Cache', '1.1.1' ], - 'Progress_Planner\Widgets\Activity_Scores' => [ 'Progress_Planner\Admin\Widgets\Activity_Scores', '1.1.1' ], - 'Progress_Planner\Widgets\Badge_Streak' => [ 'Progress_Planner\Admin\Widgets\Badge_Streak', '1.1.1' ], - 'Progress_Planner\Widgets\Challenge' => [ 'Progress_Planner\Admin\Widgets\Challenge', '1.1.1' ], - 'Progress_Planner\Widgets\Published_Content' => [ 'Progress_Planner\Admin\Widgets\Published_Content', '1.1.1' ], - 'Progress_Planner\Widgets\Todo' => [ 'Progress_Planner\Admin\Widgets\Todo', '1.1.1' ], - 'Progress_Planner\Widgets\Whats_New' => [ 'Progress_Planner\Admin\Widgets\Whats_New', '1.1.1' ], - 'Progress_Planner\Widgets\Widget' => [ 'Progress_Planner\Admin\Widgets\Widget', '1.1.1' ], 'Progress_Planner\Rest_API_Stats' => [ 'Progress_Planner\Rest\Stats', '1.1.1' ], 'Progress_Planner\Rest_API_Tasks' => [ 'Progress_Planner\Rest\Tasks', '1.1.1' ], 'Progress_Planner\Data_Collector\Base_Data_Collector' => [ 'Progress_Planner\Suggested_Tasks\Data_Collector\Base_Data_Collector', '1.1.1' ], @@ -45,48 +38,9 @@ class Deprecations { 'Progress_Planner\Data_Collector\Sample_Page' => [ 'Progress_Planner\Suggested_Tasks\Data_Collector\Sample_Page', '1.1.1' ], 'Progress_Planner\Data_Collector\Uncategorized_Category' => [ 'Progress_Planner\Suggested_Tasks\Data_Collector\Uncategorized_Category', '1.1.1' ], 'Progress_Planner\Chart' => [ 'Progress_Planner\UI\Chart', '1.1.1' ], - 'Progress_Planner\Popover' => [ 'Progress_Planner\UI\Popover', '1.1.1' ], 'Progress_Planner\Debug_Tools' => [ 'Progress_Planner\Utils\Debug_Tools', '1.1.1' ], 'Progress_Planner\Onboard' => [ 'Progress_Planner\Utils\Onboard', '1.1.1' ], 'Progress_Planner\Playground' => [ 'Progress_Planner\Utils\Playground', '1.1.1' ], - - 'Progress_Planner\Admin\Widgets\Published_Content' => [ 'Progress_Planner\Admin\Widgets\Content_Activity', '1.3.0' ], - - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Task_Local' => [ 'Progress_Planner\Suggested_Tasks\Task', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Local_Tasks_Interface' => [ 'Progress_Planner\Suggested_Tasks\Tasks_Interface', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks_Manager' => [ 'Progress_Planner\Suggested_Tasks\Tasks_Manager', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Local_Task_Factory' => [ 'Progress_Planner\Suggested_Tasks\Task_Factory', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time' => [ 'Progress_Planner\Suggested_Tasks\Providers\Task', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive' => [ 'Progress_Planner\Suggested_Tasks\Providers\Repetitive', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Local_Tasks' => [ 'Progress_Planner\Suggested_Tasks\Providers\Tasks', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\User' => [ 'Progress_Planner\Suggested_Tasks\Providers\User', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Add_Yoast_Providers' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Add_Yoast_Providers', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Archive_Author' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Archive_Author', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Archive_Date' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Archive_Date', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Archive_Format' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Archive_Format', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Emoji_Scripts' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Emoji_Scripts', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Feed_Authors' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Feed_Authors', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Feed_Global_Comments' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Crawl_Settings_Feed_Global_Comments', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Media_Pages' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Media_Pages', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Organization_Logo' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Organization_Logo', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Integrations\Yoast\Yoast_Provider' => [ 'Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Yoast_Provider', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Blog_Description' => [ 'Progress_Planner\Suggested_Tasks\Providers\Blog_Description', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Debug_Display' => [ 'Progress_Planner\Suggested_Tasks\Providers\Debug_Display', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Disable_Comments' => [ 'Progress_Planner\Suggested_Tasks\Providers\Disable_Comments', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Hello_World' => [ 'Progress_Planner\Suggested_Tasks\Providers\Hello_World', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Permalink_Structure' => [ 'Progress_Planner\Suggested_Tasks\Providers\Permalink_Structure', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Php_Version' => [ 'Progress_Planner\Suggested_Tasks\Providers\Php_Version', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Remove_Inactive_Plugins' => [ 'Progress_Planner\Suggested_Tasks\Providers\Remove_Inactive_Plugins', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Rename_Uncategorized_Category' => [ 'Progress_Planner\Suggested_Tasks\Providers\Rename_Uncategorized_Category', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Sample_Page' => [ 'Progress_Planner\Suggested_Tasks\Providers\Sample_Page', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Search_Engine_Visibility' => [ 'Progress_Planner\Suggested_Tasks\Providers\Search_Engine_Visibility', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Set_Valuable_Post_Types' => [ 'Progress_Planner\Suggested_Tasks\Providers\Set_Valuable_Post_Types', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Site_Icon' => [ 'Progress_Planner\Suggested_Tasks\Providers\Site_Icon', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Core_Update' => [ 'Progress_Planner\Suggested_Tasks\Providers\Core_Update', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Create' => [ 'Progress_Planner\Suggested_Tasks\Providers\Repetitive\Create', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Review' => [ 'Progress_Planner\Suggested_Tasks\Providers\Repetitive\Review', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Remote_Tasks\Remote_Task_Factory' => [ 'Progress_Planner\Suggested_Tasks\Task_Factory', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Remote_Tasks\Remote_Task' => [ 'Progress_Planner\Suggested_Tasks\Task', '1.4.0' ], ]; /** @@ -97,13 +51,6 @@ class Deprecations { const BASE_METHODS = [ 'get_query' => [ 'get_activities__query', '1.1.1' ], 'get_date' => [ 'get_utils__date', '1.1.1' ], - 'get_widgets__suggested_tasks' => [ 'get_admin__widgets__suggested_tasks', '1.1.1' ], - 'get_widgets__activity_scores' => [ 'get_admin__widgets__activity_scores', '1.1.1' ], - 'get_widgets__todo' => [ 'get_admin__widgets__todo', '1.1.1' ], - 'get_widgets__challenge' => [ 'get_admin__widgets__challenge', '1.1.1' ], - 'get_widgets__badge_streak' => [ 'get_admin__widgets__badge_streak', '1.1.1' ], - 'get_widgets__published_content' => [ 'get_admin__widgets__published_content', '1.1.1' ], - 'get_widgets__whats_new' => [ 'get_admin__widgets__whats_new', '1.1.1' ], 'get_onboard' => [ 'get_utils__onboard', '1.1.1' ], 'get_cache' => [ 'get_utils__cache', '1.1.1' ], 'get_rest_api_stats' => [ 'get_rest__stats', '1.1.1' ], @@ -112,8 +59,5 @@ class Deprecations { 'get_debug_tools' => [ 'get_utils__debug_tools', '1.1.1' ], 'get_playground' => [ 'get_utils__playground', '1.1.1' ], 'get_chart' => [ 'get_ui__chart', '1.1.1' ], - 'get_popover' => [ 'get_ui__popover', '1.1.1' ], - - 'get_admin__widgets__published_content' => [ 'get_admin__widgets__content_activity', '1.3.0' ], ]; } diff --git a/classes/utils/class-plugin-migration-helpers.php b/classes/utils/class-plugin-migration-helpers.php index fec0611a51..817a8ec697 100644 --- a/classes/utils/class-plugin-migration-helpers.php +++ b/classes/utils/class-plugin-migration-helpers.php @@ -30,11 +30,11 @@ public static function parse_task_data_from_task_id( $task_id ) { // Check if the task ID ends with a '-12345' or not, if not that would be mostly one time tasks. if ( $last_pos === false || ! \preg_match( '/-\d+$/', $task_id ) ) { - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_id ); + // One-time task - task_id is the provider_id. return new Task( [ 'task_id' => $task_id, - 'provider_id' => $task_provider ? $task_provider->get_provider_id() : '', + 'provider_id' => $task_id, ] ); } @@ -47,12 +47,10 @@ public static function parse_task_data_from_task_id( $task_id ) { $task_provider_id = 'create-post'; } - $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_provider_id ); - return new Task( [ 'task_id' => $task_id, - 'provider_id' => $task_provider ? $task_provider->get_provider_id() : '', + 'provider_id' => $task_provider_id, 'date' => \substr( $task_id, $last_pos + 1 ), ] ); diff --git a/classes/utils/class-plugin-utils.php b/classes/utils/class-plugin-utils.php new file mode 100644 index 0000000000..cd370c3e3f --- /dev/null +++ b/classes/utils/class-plugin-utils.php @@ -0,0 +1,79 @@ + $activity_score->get_score(), - 'checklist' => $activity_score->get_checklist_results(), + 'score' => $this->get_activity_score(), + 'checklist' => $this->get_checklist_results(), ]; - // Get the badges. - $badges = \array_merge( - \progress_planner()->get_badges()->get_badges( 'content' ), - \progress_planner()->get_badges()->get_badges( 'maintenance' ), - \progress_planner()->get_badges()->get_badges( 'monthly_flat' ) - ); + // Get the badges from saved stats. + // Badge calculations are now handled in React, so we return saved progress. + $settings = \progress_planner()->get_settings(); + $saved_badges = $settings->get( 'badges', [] ); + + // Badge name mapping (for external API compatibility). + $badge_names = [ + 'content-curator' => \__( 'Content Curator', 'progress-planner' ), + 'revision-ranger' => \__( 'Revision Ranger', 'progress-planner' ), + 'purposeful-publisher' => \__( 'Purposeful Publisher', 'progress-planner' ), + 'progress-padawan' => \__( 'Progress Padawan', 'progress-planner' ), + 'maintenance-maniac' => \__( 'Maintenance Maniac', 'progress-planner' ), + 'super-site-specialist' => \__( 'Super Site Specialist', 'progress-planner' ), + ]; $data['badges'] = []; - foreach ( $badges as $badge ) { - $data['badges'][ $badge->get_id() ] = \array_merge( + foreach ( $saved_badges as $badge_id => $badge_data ) { + // Ensure badge_id is a string for array key access. + $badge_id = (string) $badge_id; + // Get badge name (for monthly badges, generate from ID). + $badge_name = $badge_names[ $badge_id ] ?? ''; + if ( empty( $badge_name ) && \str_starts_with( $badge_id, 'monthly-' ) ) { + // Generate monthly badge name from ID. + $parts = \explode( '-', \str_replace( 'monthly-', '', $badge_id ) ); + if ( \count( $parts ) === 2 ) { + $year = (int) $parts[0]; + $month = (int) \str_replace( 'm', '', $parts[1] ); + $months = [ + 1 => 'Jack January', + 2 => 'Felix February', + 3 => 'Mary March', + 4 => 'Avery April', + 5 => 'Matteo May', + 6 => 'Jasmine June', + 7 => 'Joey July', + 8 => 'Abed August', + 9 => 'Sam September', + 10 => 'Oksana October', + 11 => 'Noah November', + 12 => 'Daisy December', + ]; + if ( isset( $months[ $month ] ) ) { + $badge_name = $months[ $month ]; + } + } + } + + $data['badges'][ $badge_id ] = \array_merge( [ - 'id' => $badge->get_id(), - 'name' => $badge->get_name(), + 'id' => $badge_id, + 'name' => $badge_name, ], - $badge->progress_callback() + $badge_data ); } @@ -137,4 +175,85 @@ public function get_system_status() { return $data; } + + /** + * Get the activity score. + * + * @return int The score. + */ + private function get_activity_score() { + $activities = \progress_planner()->get_activities__query()->query_activities( + // Use 31 days to take into account the activities score decay from previous activities. + [ 'start_date' => new \DateTime( '-31 days' ) ] + ); + + $score = 0; + $current_date = new \DateTime(); + foreach ( $activities as $activity ) { + $score += $activity->get_points( $current_date ); + } + $score = \min( 100, \max( 0, $score ) ); + + // Get the number of pending updates. + $pending_updates = \wp_get_update_data()['counts']['total']; + + // Reduce points for pending updates. + $score -= \min( \min( $score / 2, 25 ), $pending_updates * 5 ); + return (int) \floor( $score ); + } + + /** + * Get the checklist results. + * + * @return array The checklist results. + */ + private function get_checklist_results() { + $items = $this->get_checklist(); + $results = []; + foreach ( $items as $item ) { + $label = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset + $results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset + } + return $results; + } + + /** + * Get the checklist items. + * + * @return array The checklist items. + */ + private function get_checklist() { + return [ + [ + 'label' => \esc_html__( 'published content', 'progress-planner' ), + 'callback' => fn() => \count( + \progress_planner()->get_activities__query()->query_activities( + [ + 'start_date' => new \DateTime( '-7 days' ), + 'category' => 'content', + 'type' => 'publish', + ] + ) + ) > 0, + ], + [ + 'label' => \esc_html__( 'updated content', 'progress-planner' ), + 'callback' => fn() => \count( + \progress_planner()->get_activities__query()->query_activities( + [ + 'start_date' => new \DateTime( '-7 days' ), + 'category' => 'content', + 'type' => 'update', + ] + ) + ) > 0, + ], + [ + 'label' => 0 === \wp_get_update_data()['counts']['total'] + ? \esc_html__( 'performed all updates', 'progress-planner' ) + : '' . \esc_html__( 'Perform all updates', 'progress-planner' ) . '', + 'callback' => fn() => ! \wp_get_update_data()['counts']['total'], + ], + ]; + } } diff --git a/classes/utils/traits/class-input-sanitizer.php b/classes/utils/traits/class-input-sanitizer.php index e0cb42a729..5119391180 100644 --- a/classes/utils/traits/class-input-sanitizer.php +++ b/classes/utils/traits/class-input-sanitizer.php @@ -21,6 +21,8 @@ * * Can be used in providers, widgets, admin classes, or any class * that needs to safely handle user input. + * + * @phpstan-ignore-next-line trait.unused */ trait Input_Sanitizer { diff --git a/classes/wp-cli/class-get-stats-command.php b/classes/wp-cli/class-get-stats-command.php index bb05c85a6e..d3a1ece252 100644 --- a/classes/wp-cli/class-get-stats-command.php +++ b/classes/wp-cli/class-get-stats-command.php @@ -7,11 +7,6 @@ namespace Progress_Planner\WP_CLI; -use WP_CLI, WP_CLI_Command; - -use Progress_Planner\Base; -use Progress_Planner\Admin\Widgets\Activity_Scores; - if ( ! \class_exists( 'WP_CLI_Command' ) ) { return; } diff --git a/classes/wp-cli/class-task-command.php b/classes/wp-cli/class-task-command.php index 01b179ed19..bbaf9dc127 100644 --- a/classes/wp-cli/class-task-command.php +++ b/classes/wp-cli/class-task-command.php @@ -309,7 +309,7 @@ private function delete_task( $task_id, $force ) { * : The points value for the task. Default: 1 * * [--provider_id=] - * : The provider ID. Default: "collaborator" + * : The provider ID. Default: "user" * * [--status=] * : The task status. Default: "pending" @@ -335,7 +335,7 @@ public function create( $args, $assoc_args ) { $title = isset( $assoc_args['title'] ) ? $assoc_args['title'] : ''; $description = isset( $assoc_args['description'] ) ? $assoc_args['description'] : 'Test description '; $points = isset( $assoc_args['points'] ) ? (int) $assoc_args['points'] : 1; - $provider_id = isset( $assoc_args['provider_id'] ) ? $assoc_args['provider_id'] : 'collaborator'; + $provider_id = isset( $assoc_args['provider_id'] ) ? $assoc_args['provider_id'] : 'user'; $status = isset( $assoc_args['status'] ) ? $assoc_args['status'] : 'pending'; $is_completed_callback = isset( $assoc_args['is_completed_callback'] ) ? $assoc_args['is_completed_callback'] : null; $dismissable = isset( $assoc_args['dismissable'] ) ? $assoc_args['dismissable'] : true; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000000..abc354d4bd --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +const defaultConfig = require( '@wordpress/scripts/config/jest-unit.config' ); + +module.exports = { + ...defaultConfig, + roots: [ '/assets/src' ], + setupFilesAfterEnv: [ '/assets/src/__tests__/setup.js' ], + moduleNameMapper: { + ...( defaultConfig.moduleNameMapper || {} ), + '@wordpress/html-entities': + '/assets/src/__tests__/mocks/html-entities.js', + '(.*)/services/apiFetchCache': + '/assets/src/__tests__/mocks/apiFetchCache.js', + }, + testMatch: [ '**/__tests__/**/*.test.[jt]s?(x)' ], + testPathIgnorePatterns: [ '/node_modules/', '/tests/' ], + collectCoverageFrom: [ + 'assets/src/**/*.{js,jsx}', + '!assets/src/**/__tests__/**', + ], +}; diff --git a/package-lock.json b/package-lock.json index da733a5a3c..2f1050eb3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,18 @@ "version": "1.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "driver.js": "^1.3.1" + "@wordpress/api-fetch": "*", + "@wordpress/element": "*", + "@wordpress/hooks": "*", + "@wordpress/i18n": "*", + "canvas-confetti": "^1.9.2", + "driver.js": "^1.3.1", + "zustand": "^5.0.9" }, "devDependencies": { "@playwright/test": "*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@wordpress/scripts": "*", "@wordpress/stylelint-config": "*", "dotenv": "*", @@ -24,6 +32,13 @@ "npm": ">=10.2.3" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -4069,6 +4084,168 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tannin/compile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.1.0.tgz", + "integrity": "sha512-n8m9eNDfoNZoxdvWiTfW/hSPhehzLJ3zW7f8E7oT6mCROoMNWCB4TYtv041+2FMAxweiE0j7i1jubQU4MEC/Gg==", + "license": "MIT", + "dependencies": { + "@tannin/evaluate": "^1.2.0", + "@tannin/postfix": "^1.1.0" + } + }, + "node_modules/@tannin/evaluate": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@tannin/evaluate/-/evaluate-1.2.0.tgz", + "integrity": "sha512-3ioXvNowbO/wSrxsDG5DKIMxC81P0QrQTYai8zFNY+umuoHWRPbQ/TuuDEOju9E+jQDXmj6yI5GyejNuh8I+eg==", + "license": "MIT" + }, + "node_modules/@tannin/plural-forms": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tannin/plural-forms/-/plural-forms-1.1.0.tgz", + "integrity": "sha512-xl9R2mDZO/qiHam1AgMnAES6IKIg7OBhcXqy6eDsRCdXuxAFPcjrej9HMjyCLE0DJ/8cHf0i5OQTstuBRhpbHw==", + "license": "MIT", + "dependencies": { + "@tannin/compile": "^1.1.0" + } + }, + "node_modules/@tannin/postfix": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", + "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==", + "license": "MIT" + }, + "node_modules/@tannin/sprintf": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@tannin/sprintf/-/sprintf-1.3.3.tgz", + "integrity": "sha512-RwARl+hFwhzy0tg9atWcchLFvoQiOh4rrP7uG2N5E4W80BPCUX0ElcUR9St43fxB9EfjsW2df9Qp+UsTbvQDjA==", + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4093,6 +4270,14 @@ "node": ">=10.13.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4396,6 +4581,12 @@ "@types/pg": "*" } }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4408,6 +4599,25 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -5018,6 +5228,20 @@ } } }, + "node_modules/@wordpress/api-fetch": { + "version": "7.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.36.0.tgz", + "integrity": "sha512-71yTZi1tSqYbfzT5O+Cx2L2gWpp3y+twdch8mGIzpRmNDz6L/NvntIko7Qmc73tu3dSVC7KakvEmCduOaDNKRQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^6.9.0", + "@wordpress/url": "^4.36.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/babel-preset-default": { "version": "8.28.0", "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.28.0.tgz", @@ -5276,6 +5500,35 @@ "@playwright/test": ">=1" } }, + "node_modules/@wordpress/element": { + "version": "6.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.36.0.tgz", + "integrity": "sha512-6Ym/Ucik49skz1XJ2GRXENoMjJx7EYnY+fbfor9KtChiCd9/3H4/rI4sZgewVPIO//fCKEk7G30HoR+xB7GZMQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", + "@wordpress/escape-html": "^3.36.0", + "change-case": "^4.1.2", + "is-plain-object": "^5.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/escape-html": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.36.0.tgz", + "integrity": "sha512-0FvvlVPv+7X8lX5ExcTh6ib/xckGIuVXdnHglR3rZC1MJI682cx4JRUR0Igk6nKyPS8UiSQCKtN3U1aSPtZaCg==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/eslint-plugin": { "version": "22.14.0", "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-22.14.0.tgz", @@ -5335,6 +5588,36 @@ "node": ">=10" } }, + "node_modules/@wordpress/hooks": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.36.0.tgz", + "integrity": "sha512-9kB2lanmVrubJEqWDSHtyUx7q4ZAWGArakY/GsUdlFsnf9m+VmQLQl92uCpHWYjKzHec1hwcBhBB3Tu9aBWDtQ==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/i18n": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.9.0.tgz", + "integrity": "sha512-ke4BPQUHmj82mwYoasotKt3Sghf0jK4vec56cWxwnzUvqq7LMy/0H7F5NzJ4CY378WS+TOdLbqmIb4sj+f7eog==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@tannin/sprintf": "^1.3.2", + "@wordpress/hooks": "^4.36.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/jest-console": { "version": "8.28.0", "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.28.0.tgz", @@ -5518,6 +5801,19 @@ "stylelint-scss": "^6.4.0" } }, + "node_modules/@wordpress/url": { + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.36.0.tgz", + "integrity": "sha512-b61pCnJCjaxIiiH/+leR3IVZlKUlSP/PnYCFg1cLa9Qv8TQBr5REnmtBDnrfNzaHEP7uE+A81BJe5lVFP/AQgw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "remove-accents": "^0.5.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/warning": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.28.0.tgz", @@ -6736,7 +7032,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" @@ -6812,11 +7107,20 @@ } ] }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/capital-case": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -6843,7 +7147,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", - "dev": true, "dependencies": { "camel-case": "^4.1.2", "capital-case": "^1.0.4", @@ -7199,7 +7502,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", - "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -7551,6 +7853,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -7696,6 +8005,12 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "dev": true }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/cwd": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/cwd/-/cwd-0.10.0.tgz", @@ -8032,6 +8347,17 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -8121,6 +8447,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8193,7 +8527,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -8311,6 +8644,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -10275,6 +10617,16 @@ "node": ">= 14" } }, + "node_modules/gettext-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gettext-parser/-/gettext-parser-1.4.0.tgz", + "integrity": "sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA==", + "license": "MIT", + "dependencies": { + "encoding": "^0.1.12", + "safe-buffer": "^5.1.1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10578,7 +10930,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dev": true, "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" @@ -10856,7 +11207,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -11491,7 +11841,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -12427,8 +12776,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "3.14.1", @@ -13014,7 +13362,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -13026,7 +13373,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, "dependencies": { "tslib": "^2.0.3" } @@ -13040,6 +13386,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -13283,6 +13640,12 @@ "node": ">= 4.0.0" } }, + "node_modules/memize": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/memize/-/memize-2.1.1.tgz", + "integrity": "sha512-8Nl+i9S5D6KXnruM03Jgjb+LwSupvR13WBr4hJegaaEyobvowCVupi79y2WSiWvO1mzBWxPwEYE5feCe8vyA5w==", + "license": "MIT" + }, "node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -13643,7 +14006,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" @@ -14232,7 +14594,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -14320,7 +14681,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -14330,7 +14690,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", - "dev": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -15709,7 +16068,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15721,8 +16079,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15988,6 +16344,12 @@ "node": ">=6" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -16245,7 +16607,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -16297,8 +16658,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.90.0", @@ -16376,8 +16736,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -16527,7 +16885,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", - "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -16901,7 +17258,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -17983,6 +18339,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/tannin": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tannin/-/tannin-1.2.0.tgz", + "integrity": "sha512-U7GgX/RcSeUETbV7gYgoz8PD7Ni4y95pgIP/Z6ayI3CfhSujwKEBlGFTCRN+Aqnuyf4AN2yHL+L8x+TCGjb9uA==", + "license": "MIT", + "dependencies": { + "@tannin/plural-forms": "^1.1.0" + } + }, "node_modules/tapable": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", @@ -18358,8 +18723,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -18654,7 +19018,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", - "dev": true, "dependencies": { "tslib": "^2.0.3" } @@ -18663,7 +19026,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dev": true, "dependencies": { "tslib": "^2.0.3" } @@ -19611,6 +19973,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index add3622958..eda9440ffd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ }, "devDependencies": { "@playwright/test": "*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@wordpress/scripts": "*", "@wordpress/stylelint-config": "*", "dotenv": "*", @@ -22,20 +24,31 @@ "husky": "*" }, "scripts": { + "build": "wp-scripts build", + "start": "wp-scripts start", "format": "wp-scripts format ./assets", "lint:css": "wp-scripts lint-style \"**/*.css\"", "lint:css:fix": "npm run lint:css -- --fix", - "lint:js": "wp-scripts lint-js ./assets/js/*.js && wp-scripts lint-js ./assets/js/web-components/*.js && wp-scripts lint-js ./assets/js/widgets/*.js && wp-scripts lint-js ./assets/js/recommendations/*.js && wp-scripts lint-js ./tests/**/*.js", - "lint:js:fix": "wp-scripts lint-js ./assets/js/*.js --fix && wp-scripts lint-js ./assets/js/web-components/*.js --fix && wp-scripts lint-js ./assets/js/widgets/*.js --fix && wp-scripts lint-js ./assets/js/recommendations/*.js --fix && wp-scripts lint-js ./tests/**/*.js --fix", + "lint:js": "wp-scripts lint-js './assets/js/*.js' && wp-scripts lint-js './assets/src/**/*.js' && wp-scripts lint-js './tests/**/*.js'", + "lint:js:fix": "wp-scripts lint-js './assets/js/*.js' --fix && wp-scripts lint-js './assets/src/**/*.js' --fix && wp-scripts lint-js './tests/**/*.js' --fix", "prepare": "husky", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:sequential": "npx playwright test --project=sequential", "test:parallel": "npx playwright test --project=parallel", - "test": "npm run test:sequential && npm run test:parallel" + "test": "npm run test:sequential && npm run test:parallel", + "test:unit": "wp-scripts test-unit-js", + "test:unit:watch": "wp-scripts test-unit-js --watch", + "test:unit:coverage": "wp-scripts test-unit-js --coverage" }, "dependencies": { - "driver.js": "^1.3.1" + "@wordpress/api-fetch": "*", + "@wordpress/element": "*", + "@wordpress/hooks": "*", + "@wordpress/i18n": "*", + "canvas-confetti": "^1.9.2", + "driver.js": "^1.3.1", + "zustand": "^5.0.9" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 637bcaf9bd..ebe2ff1711 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -26,6 +26,9 @@ */coverage/* + + /build/* + *.js *.css diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ca24110c34..40de391c1d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,9 +31,6 @@ parameters: - '#Call to an undefined method Progress_Planner\\Base\:\:get_[a-zA-Z0-9\\_]+\(\).#' - '#Cannot call method modify\(\) on DateTime\|false.#' - '#Cannot call method format\(\) on DateTime\|false.#' - - - identifier: variable.undefined - path: views/popovers/email-sending.php - identifier: property.nonObject paths: - classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 366541bb7d..159dd11288 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -42,8 +42,5 @@ function _manually_load_plugin() { // Start up the WP testing environment. require "{$_tests_dir}/includes/bootstrap.php"; -// Load base provider test class. -require_once __DIR__ . '/phpunit/class-task-provider-test-trait.php'; - // Load integration test base class. require_once __DIR__ . '/phpunit/integration/class-integration-test-case.php'; diff --git a/tests/phpunit/class-task-provider-test-trait.php b/tests/phpunit/class-task-provider-test-trait.php deleted file mode 100644 index 8a724e7e86..0000000000 --- a/tests/phpunit/class-task-provider-test-trait.php +++ /dev/null @@ -1,119 +0,0 @@ -task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $this->task_provider_id ); - } - - /** - * Tear down the test case. - * - * @return void - */ - public function tear_down() { - parent::tear_down(); - - // Delete tasks. - \progress_planner()->get_suggested_tasks_db()->delete_all_recommendations(); - } - - /** - * Complete the task. - * - * @return void - */ - abstract protected function complete_task(); - - /** - * Test transforming task data to task id and back. - * - * @return void - */ - public function test_task_provider() { - // Test that the blog description is empty. - $this->assertTrue( $this->task_provider->should_add_task() ); - - // WIP, get_tasks_to_inject() is injecting tasks. - $tasks = $this->task_provider->get_tasks_to_inject(); - - // Verify that the task(s) are in the suggested tasks. - $pending_tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( - [ - 'post_status' => 'publish', - 'provider' => $this->task_provider_id, - ] - ); - - // Assert that task is in the pending tasks. - $this->assertTrue( \has_term( $this->task_provider_id, 'prpl_recommendations_provider', $pending_tasks[0]->ID ) ); - - // Complete the task. - $this->complete_task(); - - // Change the task status to pending celebration for all completed tasks. - foreach ( \progress_planner()->get_suggested_tasks()->get_tasks_manager()->evaluate_tasks() as $task ) { - // Change the task status to pending celebration. - \progress_planner()->get_suggested_tasks_db()->update_recommendation( - $task->get_data()['ID'], - [ 'post_status' => 'pending' ] - ); - // Verify that the task(s) we're testing is pending celebration. - $this->assertTrue( 'pending' === \get_post_status( $task->get_data()['ID'] ) ); - } - - // Verify that the task(s) we're testing is completed. - foreach ( $tasks as $post_id ) { - \progress_planner()->get_suggested_tasks_db()->update_recommendation( - $post_id, - [ 'post_status' => 'trash' ] - ); - $this->assertTrue( 'trash' === \get_post_status( $post_id ) ); - } - } -} diff --git a/tests/phpunit/test-class-admin-page-settings.php b/tests/phpunit/test-class-admin-page-settings.php index d1feac7679..bf851fb867 100644 --- a/tests/phpunit/test-class-admin-page-settings.php +++ b/tests/phpunit/test-class-admin-page-settings.php @@ -53,4 +53,189 @@ public function test_get_settings_includes_page_types() { // Settings should be an array (may be empty if no page types). $this->assertIsArray( $settings ); } + + /** + * Test should_show_setting returns true for valid page types. + * + * @return void + */ + public function test_should_show_setting_returns_true_for_valid_types() { + // These types should be shown in settings (show_in_settings: yes). + $showable_types = [ 'homepage', 'contact', 'about', 'faq' ]; + + foreach ( $showable_types as $type ) { + $result = $this->page_settings_instance->should_show_setting( $type ); + $this->assertTrue( $result, "Type {$type} should be shown in settings" ); + } + } + + /** + * Test should_show_setting returns false for hidden types. + * + * @return void + */ + public function test_should_show_setting_returns_false_for_hidden_types() { + // These types have show_in_settings: no. + $hidden_types = [ 'blog', 'product-page' ]; + + foreach ( $hidden_types as $type ) { + $result = $this->page_settings_instance->should_show_setting( $type ); + $this->assertFalse( $result, "Type {$type} should not be shown in settings" ); + } + } + + /** + * Test should_show_setting returns true for unknown types. + * + * @return void + */ + public function test_should_show_setting_returns_true_for_unknown_types() { + $result = $this->page_settings_instance->should_show_setting( 'unknown-type' ); + $this->assertTrue( $result ); + } + + /** + * Test save_settings saves redirect on login user meta. + * + * @return void + */ + public function test_save_settings_saves_redirect_on_login() { + // Create a user and log in. + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + \wp_set_current_user( $user_id ); + + // Save settings with redirect enabled. + $this->page_settings_instance->save_settings( true ); + + // Verify the user meta was saved (user_meta stores as string). + $meta = \get_user_meta( $user_id, 'prpl_redirect_on_login', true ); + $this->assertEquals( '1', $meta ); + + // Save settings with redirect disabled. + $this->page_settings_instance->save_settings( false ); + + // Verify the user meta was updated (empty string for false). + $meta = \get_user_meta( $user_id, 'prpl_redirect_on_login', true ); + $this->assertEquals( '', $meta ); + } + + /** + * Test save_post_types saves post types. + * + * @return void + */ + public function test_save_post_types_saves_array() { + $post_types = [ 'post', 'page', 'custom_post_type' ]; + + $this->page_settings_instance->save_post_types( $post_types ); + + $saved = \progress_planner()->get_settings()->get( 'include_post_types' ); + + $this->assertEquals( $post_types, $saved ); + } + + /** + * Test save_post_types with empty array falls back to defaults. + * + * @return void + */ + public function test_save_post_types_empty_defaults() { + $this->page_settings_instance->save_post_types( [] ); + + $saved = \progress_planner()->get_settings()->get( 'include_post_types' ); + + // Should contain at least post and page. + $this->assertIsArray( $saved ); + $this->assertContains( 'post', $saved ); + $this->assertContains( 'page', $saved ); + } + + /** + * Test set_page_values with empty array does nothing. + * + * @return void + */ + public function test_set_page_values_empty_array() { + // Should not throw an error. + $this->page_settings_instance->set_page_values( [] ); + $this->assertTrue( true ); + } + + /** + * Test set_page_values assigns page type. + * + * @return void + */ + public function test_set_page_values_assigns_page_type() { + // Create a test page. + $page_id = $this->factory->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Test About Page', + 'post_status' => 'publish', + ] + ); + + // Set the page value. + $pages = [ + 'about' => [ + 'id' => $page_id, + 'have_page' => 'yes', + ], + ]; + + $this->page_settings_instance->set_page_values( $pages ); + + // Verify the term was assigned. + $terms = \get_the_terms( $page_id, \Progress_Planner\Page_Types::TAXONOMY_NAME ); + + $this->assertIsArray( $terms ); + $this->assertEquals( 'about', $terms[0]->slug ); + + // Cleanup. + \wp_delete_post( $page_id, true ); + } + + /** + * Test set_page_values with not-applicable sets no page needed. + * + * @return void + */ + public function test_set_page_values_not_applicable() { + $pages = [ + 'faq' => [ + 'id' => 0, + 'have_page' => 'not-applicable', + ], + ]; + + $this->page_settings_instance->set_page_values( $pages ); + + // Verify the page is marked as not needed. + $is_needed = \progress_planner()->get_page_types()->is_page_needed( 'faq' ); + $this->assertFalse( $is_needed ); + + // Cleanup. + \progress_planner()->get_page_types()->set_no_page_needed( 'faq', false ); + } + + /** + * Test get_settings returns expected structure. + * + * @return void + */ + public function test_get_settings_returns_expected_structure() { + $settings = $this->page_settings_instance->get_settings(); + + // If there are settings, each should have required keys. + foreach ( $settings as $slug => $setting ) { + $this->assertArrayHasKey( 'id', $setting ); + $this->assertArrayHasKey( 'value', $setting ); + $this->assertArrayHasKey( 'isset', $setting ); + $this->assertArrayHasKey( 'title', $setting ); + $this->assertArrayHasKey( 'type', $setting ); + $this->assertEquals( $slug, $setting['id'] ); + $this->assertEquals( 'page-select', $setting['type'] ); + } + } } diff --git a/tests/phpunit/test-class-admin-page.php b/tests/phpunit/test-class-admin-page.php index 5a67ef50e1..c9843ff77b 100644 --- a/tests/phpunit/test-class-admin-page.php +++ b/tests/phpunit/test-class-admin-page.php @@ -31,72 +31,6 @@ public function setUp(): void { $this->page_instance = new Page(); } - /** - * Test get_widgets returns array. - * - * @return void - */ - public function test_get_widgets() { - $widgets = $this->page_instance->get_widgets(); - - $this->assertIsArray( $widgets ); - $this->assertNotEmpty( $widgets ); - } - - /** - * Test get_widgets returns widget instances. - * - * @return void - */ - public function test_get_widgets_returns_widget_instances() { - $widgets = $this->page_instance->get_widgets(); - - foreach ( $widgets as $widget ) { - $this->assertInstanceOf( \Progress_Planner\Admin\Widgets\Widget::class, $widget ); - } - } - - /** - * Test get_widgets applies filter. - * - * @return void - */ - public function test_get_widgets_applies_filter() { - \add_filter( - 'progress_planner_admin_widgets', - function ( $widgets ) { - return \array_slice( $widgets, 0, 1 ); - } - ); - - $widgets = $this->page_instance->get_widgets(); - - $this->assertCount( 1, $widgets ); - } - - /** - * Test get_widget returns widget by ID. - * - * @return void - */ - public function test_get_widget() { - $widget = $this->page_instance->get_widget( 'suggested-tasks' ); - - $this->assertInstanceOf( \Progress_Planner\Admin\Widgets\Widget::class, $widget ); - $this->assertEquals( 'suggested-tasks', $widget->get_id() ); - } - - /** - * Test get_widget returns void for non-existent widget. - * - * @return void - */ - public function test_get_widget_not_found() { - $widget = $this->page_instance->get_widget( 'non-existent-widget' ); - - $this->assertNull( $widget ); - } - /** * Test add_page registers admin menu. * @@ -185,7 +119,7 @@ public function test_clear_activity_scores_cache() { $activity->category = 'content'; // Set a cache value. - $cache_key = \progress_planner()->get_admin__widgets__activity_scores()->get_cache_key(); + $cache_key = 'activities_weekly_post_record'; \progress_planner()->get_settings()->set( $cache_key, [ 'test' => 'data' ] ); // Clear cache. @@ -210,7 +144,7 @@ public function test_clear_activity_scores_cache_non_content() { $activity->category = 'maintenance'; // Set a cache value. - $cache_key = \progress_planner()->get_admin__widgets__activity_scores()->get_cache_key(); + $cache_key = 'activities_weekly_post_record'; \progress_planner()->get_settings()->set( $cache_key, [ 'test' => 'data' ] ); // Clear cache. @@ -234,4 +168,172 @@ public function test_render_page() { $this->assertTrue( true ); } + + /** + * Test admin_footer outputs CSS. + * + * @return void + */ + public function test_admin_footer_outputs_css() { + \ob_start(); + $this->page_instance->admin_footer(); + $output = \ob_get_clean(); + + $this->assertStringContainsString( '