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 039394c81b..d07517b2f3 100644 --- a/.github/workflows/plugin-check.yml +++ b/.github/workflows/plugin-check.yml @@ -22,10 +22,10 @@ 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.0.6 with: - build-dir: './build/${{ github.event.repository.name }}' + build-dir: './plugin-check-build/${{ github.event.repository.name }}' 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 09733f4e85..3576dee789 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. \*------------------------------------*/ @@ -209,54 +191,29 @@ button.prpl-info-icon { /*------------------------------------*\ Header & logo. \*------------------------------------*/ -.prpl-header { - margin-bottom: 2rem; - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - - .prpl-header-logo img { - height: 100px; - } +.prpl-header-logo img { + height: 100px; } .prpl-header-logo svg { height: 88px; } -.prpl-header-right { - display: flex; - gap: var(--prpl-padding); - align-items: center; - - .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); +.prpl-header-right .prpl-info-icon { - svg { - width: 1rem; - height: 1rem; + svg { + width: 1rem; + height: 1rem; - & path { - fill: currentcolor; - } + & 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); - } + &: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); } } @@ -549,15 +506,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/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/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..2c4111d167 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. + * + * 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 * * Dependencies: progress-planner/web-components/prpl-badge */ +/* 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; diff --git a/assets/css/page-widgets/suggested-tasks.css b/assets/css/page-widgets/suggested-tasks.css index 8fa3f87a79..2797497622 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. + * + * 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: progress-planner/suggested-task, progress-planner/web-components/prpl-badge */ +/* 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..c102cd0fa7 100644 --- a/assets/css/page-widgets/todo.css +++ b/assets/css/page-widgets/todo.css @@ -1,9 +1,22 @@ /** - * TODOs widget. + * TODO Widget CSS. + * + * 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: progress-planner/suggested-task */ +/* 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 index e1b28ee8e5..70ad5224c2 100644 --- a/assets/css/suggested-task.css +++ b/assets/css/suggested-task.css @@ -1,243 +1,24 @@ -.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); - } - } +/* Core .prpl-suggested-task styles migrated to React inline in TaskItem.js */ - .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) { +/* Keep class name for backward compatibility with PHP-rendered tasks */ - 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%; - } - } - } +.prpl-suggested-task { + /* Input disabled styles - applies to checkboxes */ 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 base style */ .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); - } - } - + /* Snooze dropdown - PHP rendered */ .prpl-suggested-task-snooze { &.prpl-toggle-radio-group-open { @@ -308,13 +89,7 @@ } } - &[data-task-action="celebrate"] { - - .tooltip-actions { - pointer-events: none; /* Prevent clicking on actions while celebrating */ - } - } - + /* Task info - PHP rendered */ .prpl-suggested-task-info { margin-left: -30px; @@ -327,7 +102,7 @@ } } - /* Disabled checkbox styles. */ + /* Disabled checkbox tooltip - PHP rendered */ .prpl-suggested-task-disabled-checkbox-tooltip, .tooltip-actions { @@ -346,3 +121,98 @@ } } } + +/* Tooltip actions - keep for hover visibility behavior via CSS */ +.prpl-suggested-task .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; + + &::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; + } + } + + } +} + +.prpl-suggested-task:hover .tooltip-actions, +.prpl-suggested-task:focus-within .tooltip-actions { + visibility: visible; +} + +.prpl-suggested-task .tooltip-actions:has([data-tooltip-visible]) { + visibility: visible; +} + +/* Celebrate action state */ +.prpl-suggested-task[data-task-action="celebrate"] { + + .tooltip-actions { + pointer-events: none; + } +} + +/* Disabled checkbox italic title - CSS-only feature */ +.prpl-suggested-task:has(.prpl-suggested-task-disabled-checkbox-tooltip) { + + h3 { + font-style: italic; + } +} + +/* Description text */ +.prpl-suggested-task .prpl-suggested-task-description { + font-size: 0.825rem; + color: var(--prpl-color-text); + margin: 0; +} 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/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 9debd906b6..0000000000 --- a/assets/js/editor.js +++ /dev/null @@ -1,534 +0,0 @@ -/* global progressPlannerEditor, prplL10n */ -/** - * Editor script. - * - * Dependencies: wp-plugins, wp-edit-post, wp-element, 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 ); - } else if ( typeof id !== 'number' ) { - id = 0; - } - - if ( ! id ) { - id = parseInt( progressPlannerEditor.defaultPageType ); - } - - 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 = () => { - // Bail early if the page types are not set. - if ( - ! 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' ), - // Get the current term from the TAXONOMY. - value: wp.data.useSelect( ( select ) => { - const pageTypeArr = - select( 'core/editor' ).getEditedPostAttribute( TAXONOMY ); - return pageTypeArr && 0 < pageTypeArr.length - ? parseInt( pageTypeArr[ 0 ] ) - : parseInt( progressPlannerEditor.defaultPageType ); - }, [] ), - options: pageTypes, - onChange: ( value ) => { - // Update the TAXONOMY term value. - const data = {}; - data[ TAXONOMY ] = value; - wp.data.dispatch( 'core/editor' ).editPost( data ); - }, - } ); -}; - -/** - * Render the video section. - * This will display a button to open a modal with the video. - * - * @param {Object} lessonSection The lesson section. - * @return {Element} Element to render. - */ -const PrplSectionVideo = ( lessonSection ) => { - const [ isOpen, setOpen ] = useState( false ); - const openModal = () => setOpen( true ); - const closeModal = () => setOpen( false ); - - 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_label - ? 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, - }, - lesson[ sectionId ].video - ? PrplSectionVideo( lesson[ sectionId ] ) - : el( 'div', {}, '' ), - 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 ) => - select( 'core/editor' ).getEditedPostAttribute( TAXONOMY ), - [] - ); - const pageType = prplGetPageTypeSlugFromId( pageTypeID ); - - const pageTodosMeta = useSelect( ( select ) => { - const meta = select( 'core/editor' ).getEditedPostAttribute( 'meta' ); - return meta ? meta.progress_planner_page_todos : ''; - }, [] ); - const pageTodos = pageTodosMeta || ''; - - // Bail early if the page type or lessons are not set. - if ( - ! pageType || - ! progressPlannerEditor.lessons || - 0 === progressPlannerEditor.lessons.length - ) { - return el( 'div', {}, '' ); - } - - const lesson = progressPlannerEditor.lessons.find( - ( lessonItem ) => lessonItem.settings.id === pageType - ); - - if ( lesson.content_update_cycle.text ) { - lesson.content_update_cycle.text = - lesson.content_update_cycle.text.replace( - /\{page_type\}/g, - lesson.name - ); - lesson.content_update_cycle.text = - lesson.content_update_cycle.text.replace( - /\{update_cycle\}/g, - lesson.content_update_cycle.update_cycle - ); - } - - return el( - Fragment, - { - key: 'progress-planner-sidebar-lesson-items', - }, - // Update cycle content. - PrplSectionHTML( lesson, 'content_update_cycle', 'div' ), - - // Intro video & content. - PrplSectionHTML( lesson, 'intro', PanelBody ), - - // Checklist video & content. - lesson.checklist - ? el( - PanelBody, - { - key: `progress-planner-sidebar-lesson-section-checklist-content`, - title: lesson.checklist.heading, - initialOpen: false, - }, - el( - 'div', - {}, - lesson.checklist.video - ? PrplSectionVideo( lesson.checklist ) - : el( 'div', {}, '' ), - PrplTodoProgress( lesson.checklist, pageTodos ), - PrplCheckList( lesson.checklist, pageTodos ) - ) - ) - : el( 'div', {}, '' ), - - // Writers block video & content. - PrplSectionHTML( lesson, '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 = () => - 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(), - }, - el( - 'div', - { - key: 'progress-planner-sidebar-page-type-selector-wrapper', - style: { - padding: '15px', - borderBottom: '1px solid #ddd', - }, - }, - PrplRenderPageTypeSelector(), - PrplLessonItemsHTML() - ) - ) - ); - -/** - * 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 ) => { - toDoGroup.group_todos.forEach( ( item ) => { - if ( item.todo_required ) { - requiredToDos.push( item.id ); - } - } ); - } ); - } - - // Get an array of completed todo items. - const completedToDos = pageTodos - .split( ',' ) - .filter( ( item ) => requiredToDos.includes( item ) ); - - // Get the percentage of completed todo items. - const percentageComplete = Math.round( - ( completedToDos.length / requiredToDos.length ) * 100 - ); - - 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.split( ',' ).includes( item.id ), - 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 ) => { - const toDos = pageTodos.split( ',' ); - if ( checked ) { - toDos.push( item.id ); - } else { - toDos.splice( toDos.indexOf( item.id ), 1 ); - } - // Update the `progress_planner_page_todos` meta value. - wp.data.dispatch( 'core/editor' ).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 ) => - 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.map( ( item ) => - PrplCheckListItem( item, pageTodos ) - ) - ) - ) - ); - -// Register the sidebar. -registerPlugin( 'progress-planner-sidebar', { - render: PrplProgressPlannerSidebar, -} ); - -/** - * SVG Icon Component. - * - * @return {Element} Element to render. - */ -const PrplIcon = () => - el( - 'svg', - { - role: 'img', - className: 'progress-planner-icon', - xmlns: 'http://www.w3.org/2000/svg', - viewBox: '0 0 500 500', - }, - [ - el( 'path', { - key: 'path1', - id: 'path1', - stroke: 'none', - d: 'M 283.460022 172.899994 C 286.670013 173.02002 289.429993 174.640015 291.190002 177.049988 C 289.320007 166.809998 280.550018 158.880005 269.710022 158.48999 C 257.190002 158.039978 246.679993 167.820007 246.229996 180.339996 C 245.779999 192.859985 255.559998 203.369995 268.080017 203.820007 C 277.480011 204.160004 285.75 198.720001 289.480011 190.690002 C 287.649994 192.200012 285.300018 193.109985 282.740021 193.02002 C 277.190002 192.820007 272.850006 188.160004 273.050018 182.609985 C 273.25 177.059998 277.910004 172.720001 283.460022 172.919983 Z M 307.51001 305.839996 C 308.089996 307.76001 308.640015 309.700012 309.240021 311.609985 C 323.279999 356.579987 343.179993 400.359985 365.660004 435.880005 L 433.410004 305.839996 L 307.51001 305.839996 Z M 363.959991 205.970001 C 376.079987 201.470001 387.5 198.789978 397.600006 197.01001 C 375.089996 174.73999 336.359985 169.950012 336.130005 169.919983 C 337.399994 176.089996 336.709991 185.720001 333.690002 196.380005 C 330.390015 208.039978 324.309998 220.919983 314.990021 231.859985 C 311.540009 235.919983 307.630005 239.690002 303.26001 243.049988 L 303.330017 243.049988 L 303.330017 243.039978 C 303.490021 243.660004 303.710022 244.240005 303.910004 244.830002 C 306.649994 253.100006 312.52002 258.570007 318.839996 261.970001 C 325.320007 265.459991 332.209991 266.799988 336.519989 266.799988 C 342.920013 266.799988 348.399994 263.01001 350.950012 257.579987 C 351.920013 255.520004 352.5 253.25 352.5 250.820007 C 352.5 246.970001 351.079987 243.47998 348.809998 240.720001 C 346.890015 238.390015 344.350006 236.640015 341.420013 235.690002 L 386.23999 227.039978 C 379.609985 220.919983 371.519989 215.450012 363.51001 210.809998 C 361.540009 209.669983 361.820007 206.76001 363.959991 205.970001 Z', - } ), - el( 'path', { - key: 'path2', - id: 'path2', - stroke: 'none', - d: 'M 347.369995 458.369995 C 321.579987 419.529999 298.690002 370.329987 282.919983 319.829987 C 281.470001 315.200012 280.089996 310.519989 278.75 305.839996 C 277.630005 301.899994 276.529999 297.959991 275.5 294.040009 C 273.410004 286.119995 266.220001 280.579987 258.019989 280.579987 L 230.070007 280.579987 C 221.869995 280.579987 214.679993 286.109985 212.589996 294.029999 C 210.309998 302.679993 207.809998 311.350006 205.169998 319.820007 C 189.399994 370.320007 166.519989 419.519989 140.720001 458.359985 C 136.709991 464.390015 138.940002 469.98999 140.080002 472.119995 C 142.479996 476.589996 146.940002 479.26001 152.019989 479.26001 L 218.029999 479.26001 L 222 486.179993 C 226.539993 494.079987 234.990005 498.98999 244.050003 498.98999 C 253.110001 498.98999 261.559998 494.079987 266.109985 486.179993 L 270.089996 479.26001 L 336.089996 479.26001 C 339.309998 479.26001 342.279999 478.179993 344.640015 476.23999 C 345.98999 475.130005 347.149994 473.75 348.019989 472.109985 C 348.589996 471.040009 349.440002 469.089996 349.630005 466.649994 C 349.820007 464.23999 349.369995 461.339996 347.380005 458.339996 Z', - } ), - el( 'path', { - key: 'path3', - id: 'path3', - stroke: 'none', - d: 'M 361.700012 76.059998 C 354.160004 64.01001 329.320007 77.059998 302.160004 78.919983 C 287.119995 79.950012 265.110016 -31.710022 230.389999 21.929993 C 190.830002 83.029999 151.270004 -22.75 141.730011 6.100006 C 120.620003 49.369995 166.880005 90.709991 166.880005 90.709991 C 166.880005 90.709991 154.040009 98.630005 146.25 104.640015 C 140.779999 108.809998 135.430008 113.290009 130.220001 118.149994 C 109.770004 137.179993 94.18 158.470001 83.450005 182.01001 C 72.720001 205.549988 67.110001 229.589996 66.620003 254.149994 C 66.129997 278.709991 70.629997 303.25 80.160004 327.779999 C 89.68 352.309998 104.330002 375.200012 124.110001 396.459991 C 128.130005 400.779999 132.230011 404.869995 136.419998 408.76001 C 140.520004 402.450012 144.389999 396.019989 148.059998 389.5 C 152.449997 381.700012 156.559998 373.779999 160.309998 365.720001 C 159.980011 365.369995 159.650009 365.029999 159.320007 364.690002 C 159.150009 364.51001 158.980011 364.339996 158.809998 364.160004 C 143.279999 347.470001 131.639999 329.570007 123.880005 310.440002 C 116.110001 291.309998 112.380005 272.190002 112.68 253.080002 C 112.970001 233.97998 117.150002 215.399994 125.209999 197.359985 C 133.270004 179.309998 145.230011 162.910004 161.110001 148.140015 C 175.100006 135.119995 189.949997 125.309998 205.660004 118.730011 C 221.360001 112.140015 237.289993 108.75 253.419998 108.549988 C 262.470001 108.440002 272.529999 109.700012 282.929993 113.049988 C 293.25 117.320007 302.149994 122.48999 309.559998 128.399994 C 319.75 136.529999 327.170013 146.049988 331.779999 156.5 C 333.690002 160.820007 335.149994 165.299988 336.100006 169.910004 C 352.369995 141.640015 372.850006 93.950012 361.670013 76.080017 Z', - } ), - ] - ); - -/** - * 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: () => - wp.data - .dispatch( 'core/edit-post' ) - .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 0d93ac0740..0000000000 --- a/assets/js/onboarding/onboarding.js +++ /dev/null @@ -1,490 +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(); - } ); - } - } 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 f75281b630..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.textContent = 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 e16b64f38e..0000000000 --- a/assets/js/onboarding/steps/SettingsStep.js +++ /dev/null @@ -1,577 +0,0 @@ -/** - * Settings step - Configure About, Contact, FAQ pages, Post Types, and Login Destination - * Multi-step process with 5 sub-steps - */ -/* global OnboardingStep, ProgressPlannerOnboardData */ - -class PrplSettingsStep extends OnboardingStep { - subSteps = [ - 'homepage', - 'about', - 'contact', - 'faq', - 'post-types', - 'login-destination', - ]; - - 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 - }, - 'login-destination': { - redirectOnLogin: false, // Checkbox state - }, - }; - - 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/login-destination) - * @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 ); - return; - } - - // Handle login destination sub-step - if ( subStepName === 'login-destination' ) { - this.setupLoginDestinationListeners( - 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 ); - } - - /** - * Setup event listeners for login destination sub-step - * @param {string} subStepName - Name of sub-step - * @param {Object} subStepData - Data for this sub-step - * @param {Object} state - Wizard state - */ - setupLoginDestinationListeners( 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 checkbox - const checkbox = container.querySelector( - 'input[type="checkbox"][name="prpl-redirect-on-login"]' - ); - - if ( ! checkbox ) { - return; - } - - // Initialize from checkbox that is already set in template, or from saved data - if ( subStepData.redirectOnLogin === undefined ) { - subStepData.redirectOnLogin = checkbox.checked; - } else { - checkbox.checked = subStepData.redirectOnLogin; - } - - // Add change listener - checkbox.addEventListener( 'change', ( e ) => { - subStepData.redirectOnLogin = e.target.checked; - 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; - } - - // Handle login destination sub-step - always valid (checkbox is optional) - if ( subStepData.redirectOnLogin !== undefined ) { - return true; - } - - 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 ); - } ); - } - - // Add login destination - const loginData = state.data.settings[ 'login-destination' ]; - if ( loginData && loginData.redirectOnLogin ) { - formDataObj.append( 'prpl-redirect-on-login', '1' ); - } - - // 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 e7f511aa25..0000000000 --- a/assets/js/recommendations/interactive-task.js +++ /dev/null @@ -1,299 +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 ); - - // Remove the form listener once the callback is executed. - formElement.removeEventListener( - 'submit', - formSubmitHandler - ); - } ); - }; - - // Add a form listener to the form. - formElement.addEventListener( 'submit', formSubmitHandler ); - }, - - 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..5d1dd94de7 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' ); 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-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 index 7c29942314..5655a7cac2 100644 --- a/assets/js/web-components/prpl-install-plugin.js +++ b/assets/js/web-components/prpl-install-plugin.js @@ -1,4 +1,4 @@ -/* global customElements, HTMLElement, prplL10n, progressPlanner, progressPlannerAjaxRequest, prplSuggestedTask */ +/* global customElements, HTMLElement, prplL10n, prplSuggestedTask */ /* * Install Plugin * @@ -86,15 +86,34 @@ customElements.define( ${ prplL10n( 'installing' ) } `; - progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action: 'progress_planner_install_plugin', - plugin_slug: this.pluginSlug, - plugin_name: this.pluginName, - nonce: progressPlanner.nonce, + const restRoot = + ( window.wpApiSettings && window.wpApiSettings.root ) || + '/wp-json/'; + const endpoint = + restRoot.replace( /\/$/, '' ) + + '/progress-planner/v1/plugins/install'; + + fetch( endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': window.wpApiSettings?.nonce || '', }, + credentials: 'same-origin', + body: JSON.stringify( { + plugin_slug: this.pluginSlug, + } ), } ) + .then( ( response ) => { + if ( ! response.ok ) { + return response.json().then( ( error ) => { + throw new Error( + error.message || 'Installation failed' + ); + } ); + } + return response.json(); + } ) .then( () => thisObj.activatePlugin() ) .catch( ( error ) => console.error( error ) ); } @@ -107,15 +126,34 @@ customElements.define( ${ prplL10n( 'activating' ) } `; - progressPlannerAjaxRequest( { - url: progressPlanner.ajaxUrl, - data: { - action: 'progress_planner_activate_plugin', - plugin_slug: thisObj.pluginSlug, - plugin_name: thisObj.pluginName, - nonce: progressPlanner.nonce, + const restRoot = + ( window.wpApiSettings && window.wpApiSettings.root ) || + '/wp-json/'; + const endpoint = + restRoot.replace( /\/$/, '' ) + + '/progress-planner/v1/plugins/activate'; + + fetch( endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': window.wpApiSettings?.nonce || '', }, + credentials: 'same-origin', + body: JSON.stringify( { + plugin_slug: thisObj.pluginSlug, + } ), } ) + .then( ( response ) => { + if ( ! response.ok ) { + return response.json().then( ( error ) => { + throw new Error( + error.message || 'Activation failed' + ); + } ); + } + return response.json(); + } ) .then( () => { button.innerHTML = prplL10n( 'activated' ); 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/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__/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/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..67a2072d9c --- /dev/null +++ b/assets/src/components/Dashboard/DashboardWidgets.js @@ -0,0 +1,132 @@ +/** + * 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..abb6568d60 --- /dev/null +++ b/assets/src/components/Dashboard/__tests__/DashboardWidgets.test.js @@ -0,0 +1,452 @@ +/** + * 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' + ); + } ); + + it( 'passes infoIconSvg in config', () => { + const MockWidget = ( { config } ) => ( +
+ { config.infoIconSvg ? 'Has Icon' : 'No Icon' } +
+ ); + + getRegisteredWidgets.mockReturnValue( [ + { + id: 'icon-widget', + title: 'Icon Widget', + component: MockWidget, + infoIconSvg: '', + }, + ] ); + + render( ); + + expect( screen.getByTestId( 'widget-icon' ) ).toHaveTextContent( + 'Has Icon' + ); + } ); + } ); + + 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/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/InstallPluginButton.js b/assets/src/components/InstallPluginButton.js new file mode 100644 index 0000000000..9f79fb391d --- /dev/null +++ b/assets/src/components/InstallPluginButton.js @@ -0,0 +1,155 @@ +/** + * 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'; + +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/OnboardTask.js b/assets/src/components/OnboardingWizard/OnboardTask.js new file mode 100644 index 0000000000..fabdf14fc7 --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardTask.js @@ -0,0 +1,247 @@ +/** + * 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'; + +/** + * 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. + * @return {JSX.Element} OnboardTask component. + */ +export default function OnboardTask( { task, config, onComplete } ) { + const { ajaxUrl, nonce } = config; + const { completeTask, isCompleting } = useTaskCompletion( { + ajaxUrl, + nonce, + } ); + + const [ isOpen, setIsOpen ] = useState( false ); + 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 ); + onComplete?.( task.task_id ); + } catch ( error ) { + console.error( 'Failed to complete task:', error ); + } + }; + + /** + * Handle open task. + */ + const handleOpen = () => { + setIsOpen( true ); + }; + + /** + * Handle close task. + */ + const handleClose = () => { + setIsOpen( false ); + }; + + if ( isOpen ) { + 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 ) { + // Re-initialize file upload handlers after template is rendered. + const fileInputs = + el.querySelectorAll( + 'input[type="file"]' + ); + // File upload handling will be done by existing JavaScript if available. + // eslint-disable-next-line no-unused-vars + fileInputs.forEach( () => { + // File inputs are handled by existing event listeners. + } ); + } + } } + /> + ) } + { ! 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..310dbdc41e --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardingNavigation.js @@ -0,0 +1,95 @@ +/** + * OnboardingNavigation Component + * + * Left sidebar navigation showing wizard steps. + * + * @package + */ + +/** + * OnboardingNavigation component. + * + * @param {Object} props - Component props. + * @param {Array} props.steps - Array of step definitions. + * @param {number} props.currentStep - Current step index. + * @param {Function} props.onStepClick - Callback when step is clicked. + * @param {string} props.logoHtml - Logo HTML from PHP. + * @return {JSX.Element} Navigation component. + */ +export default function OnboardingNavigation( { + steps, + currentStep, + onStepClick, + logoHtml, +} ) { + return ( +
+
+
+ { steps[ currentStep ]?.title || '' } +
+
    + { steps.map( ( step, index ) => { + const isActive = index === currentStep; + const isCompleted = index < currentStep; + const stepNumber = index + 1; + + return ( +
  1. + { onStepClick ? ( + + ) : ( + <> + + { 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..66d6433b0d --- /dev/null +++ b/assets/src/components/OnboardingWizard/OnboardingStep.js @@ -0,0 +1,129 @@ +/** + * OnboardingStep Base Component + * + * Base component for all onboarding wizard steps. + * Provides common functionality like canProceed, updateNextButton, etc. + * + * @package + */ + +import { useEffect, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * 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.onBack - Callback when back 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 {Object} props.children - Step content. + * @return {JSX.Element} Step component. + */ +export default function OnboardingStep( { + wizardState, + onNext, + onBack, + canProceed = () => true, + buttonText, + buttonClass = 'prpl-btn-primary', + children, +} ) { + const nextButtonRef = useRef( null ); + const footerRef = useRef( null ); + + /** + * Update next button state based on canProceed. + */ + const updateNextButton = () => { + if ( ! nextButtonRef.current ) { + return; + } + + const canAdvance = canProceed( wizardState ); + if ( canAdvance ) { + nextButtonRef.current.classList.remove( 'prpl-btn-disabled' ); + nextButtonRef.current.disabled = false; + } else { + nextButtonRef.current.classList.add( 'prpl-btn-disabled' ); + nextButtonRef.current.disabled = true; + } + }; + + // Update button state when canProceed changes. + useEffect( () => { + updateNextButton(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ wizardState ] ); + + /** + * 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' + ); + } + } + }; + + return ( +
+ { children } +
+
+ { onBack && ( + + ) } + +
+
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/QuitConfirmation.js b/assets/src/components/OnboardingWizard/QuitConfirmation.js new file mode 100644 index 0000000000..deddc57fe0 --- /dev/null +++ b/assets/src/components/OnboardingWizard/QuitConfirmation.js @@ -0,0 +1,119 @@ +/** + * 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' ); + + 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 + ) } +

+
+
+
+ + +
+
+
+
+
+ { /* Graphic would be rendered here - neglected_site_ravi.svg */ } +
+ { __( 'Graphic placeholder', 'progress-planner' ) } +
+
+
+
+ ); +} 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..58cd6d9516 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingNavigation.test.js @@ -0,0 +1,362 @@ +/** + * Tests for OnboardingNavigation Component + */ + +import { render, screen, fireEvent } 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 click handling', () => { + it( 'calls onStepClick when step is clicked', () => { + const { container } = render( + + ); + + const buttons = container.querySelectorAll( + '.prpl-nav-step-button' + ); + fireEvent.click( buttons[ 1 ] ); + + expect( mockOnStepClick ).toHaveBeenCalledWith( 1 ); + } ); + + it( 'calls onStepClick on keyboard Enter', () => { + const { container } = render( + + ); + + const buttons = container.querySelectorAll( + '.prpl-nav-step-button' + ); + fireEvent.keyDown( buttons[ 2 ], { key: 'Enter' } ); + + expect( mockOnStepClick ).toHaveBeenCalledWith( 2 ); + } ); + + it( 'calls onStepClick on keyboard Space', () => { + const { container } = render( + + ); + + const buttons = container.querySelectorAll( + '.prpl-nav-step-button' + ); + fireEvent.keyDown( buttons[ 0 ], { key: ' ' } ); + + expect( mockOnStepClick ).toHaveBeenCalledWith( 0 ); + } ); + + it( 'renders without buttons when onStepClick is null', () => { + 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..5cd57f40dd --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingStep.test.js @@ -0,0 +1,415 @@ +/** + * 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(); + const mockOnBack = 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( 'renders back button when onBack is provided', () => { + render( + +

Content

+
+ ); + + expect( + screen.getByRole( 'button', { name: /back/i } ) + ).toBeInTheDocument(); + } ); + + it( 'does not render back button when onBack is null', () => { + render( + +

Content

+
+ ); + + expect( + screen.queryByRole( 'button', { name: /back/i } ) + ).not.toBeInTheDocument(); + } ); + + it( 'calls onBack when back button clicked', () => { + render( + +

Content

+
+ ); + + const backButton = screen.getByRole( 'button', { name: /back/i } ); + fireEvent.click( backButton ); + + expect( mockOnBack ).toHaveBeenCalled(); + } ); + + it( 'back button has secondary class', () => { + const { container } = render( + +

Content

+
+ ); + + const backButton = container.querySelector( '.prpl-btn-secondary' ); + expect( backButton ).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( 'disables next button when canProceed returns false', () => { + render( + false } + > +

Content

+
+ ); + + const nextButton = screen.getByRole( 'button', { name: /next/i } ); + expect( nextButton ).toBeDisabled(); + } ); + + 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 buttons', () => { + const { container } = render( + +

Content

+
+ ); + + const buttons = container.querySelectorAll( '.prpl-btn' ); + expect( buttons ).toHaveLength( 2 ); + } ); + } ); + + 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..3fc55e46d2 --- /dev/null +++ b/assets/src/components/OnboardingWizard/__tests__/OnboardingWizard.test.js @@ -0,0 +1,671 @@ +/** + * 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 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..3b49b0dafc --- /dev/null +++ b/assets/src/components/OnboardingWizard/index.js @@ -0,0 +1,426 @@ +/** + * 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 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 ); + + 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, + goToStep, + 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..7e2c87aa6e --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/BadgesStep.js @@ -0,0 +1,103 @@ +/** + * BadgesStep Component + * + * Step explaining the badge system. + * + * @package + */ + +import { useEffect, useRef, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; + +/** + * BadgesStep component. + * + * @param {Object} props - Component props. + * @return {JSX.Element} Badges step component. + */ +export default function BadgesStep( props ) { + const { wizardState, stepData } = props; + const gaugeRef = useRef( null ); + const badgeData = useMemo( () => stepData?.data || {}, [ stepData?.data ] ); + + useEffect( () => { + // Initialize badge gauge component if available. + if ( gaugeRef.current && window.customElements?.get( 'prpl-gauge' ) ) { + const gauge = gaugeRef.current.querySelector( 'prpl-gauge' ); + if ( gauge && badgeData.badgeId && badgeData.badgeName ) { + // Increment badge points after first task completion. + setTimeout( () => { + if ( gauge && wizardState.data.firstTaskCompleted ) { + gauge.setAttribute( + 'data-value', + ( parseFloat( + gauge.getAttribute( 'data-value' ) + ) || 0 ) + 1 + ); + } + }, 1500 ); + } + } + }, [ wizardState.data.firstTaskCompleted, badgeData ] ); + + 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 && ( + + { /* Badge will be loaded dynamically */ } + + ) } + { __( '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..26bbeaeaf7 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/EmailFrequencyStep.js @@ -0,0 +1,308 @@ +/** + * 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'; + +/** + * 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( + wizardState.data.emailFrequency || { + choice: 'weekly', + name: userFirstName, + 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 } +
+ ) } + +
+ + + +
+ + { 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..482ebd59c6 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/FirstTaskStep.js @@ -0,0 +1,161 @@ +/** + * FirstTaskStep Component + * + * Step for completing the first onboarding task. + * + * @package + */ + +import { useState, useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; +import { useTaskCompletion } from '../../../hooks/useTaskCompletion'; + +/** + * 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, isCompleting } = useTaskCompletion( { + ajaxUrl, + nonce, + } ); + + const [ isCompleted, setIsCompleted ] = useState( + wizardState.data.firstTaskCompleted || false + ); + + const task = stepData?.data?.task; + + /** + * Handle task completion. + * + * @param {Object} formValues - Form values from task. + */ + const handleCompleteTask = async ( formValues = {} ) => { + if ( ! task?.task_id ) { + return; + } + + try { + await completeTask( task.task_id, formValues ); + setIsCompleted( true ); + updateState( { + data: { + ...wizardState.data, + firstTaskCompleted: true, + }, + } ); + // Auto-advance to next step. + setTimeout( () => { + onNext(); + }, 500 ); + } catch ( error ) { + console.error( 'Failed to complete task:', error ); + } + }; + + /** + * Check if can proceed. + * + * @return {boolean} True if task is completed. + */ + const canProceed = () => { + return isCompleted; + }; + + // Skip step if no task available. + useEffect( () => { + if ( ! task ) { + onNext(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ task ] ); + + if ( ! task ) { + return null; + } + + 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' + ) } +

+
+
+
+ { task.template_html ? ( +
+ ) : ( +
+ { task.title &&

{ task.title }

} + { task.url && ( + + { task.action_label || + __( 'Do it', 'progress-planner' ) } + + ) } + +
+ ) } +
+
+
+ + ); +} diff --git a/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js b/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js new file mode 100644 index 0000000000..2f70ef1b2c --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/MoreTasksStep.js @@ -0,0 +1,232 @@ +/** + * 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 OnboardTask from '../OnboardTask'; + +const SUB_STEPS = [ 'intro', 'tasks' ]; + +/** + * 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 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' + ) } + + +
+
+
+
+ { /* Graphic would be rendered here - success_ravi.svg */ } +
+ { __( + 'Graphic placeholder', + 'progress-planner' + ) } +
+
+
+
+
+ ); + } + + // Tasks sub-step. + return ( +
+

+ { __( 'Complete more tasks', 'progress-planner' ) } +

+
+ { tasks.map( ( task ) => ( + + ) ) } +
+
+ ); + }; + + /** + * Handle next button click. + */ + const handleNext = () => { + // If on intro sub-step, continue to tasks. + if ( currentSubStep === 0 ) { + handleContinue(); + return; + } + + // If on tasks sub-step, finish onboarding. + if ( currentSubStep === SUB_STEPS.length - 1 ) { + handleFinish(); + } + }; + + /** + * Check if can proceed. + * + * @return {boolean} True if on tasks sub-step. + */ + const canProceed = () => { + return currentSubStep === SUB_STEPS.length - 1; + }; + + return ( + +
{ renderSubStep() }
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/steps/SettingsStep.js b/assets/src/components/OnboardingWizard/steps/SettingsStep.js new file mode 100644 index 0000000000..9e84dde352 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/SettingsStep.js @@ -0,0 +1,412 @@ +/** + * 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'; + +const SUB_STEPS = [ + 'homepage', + 'about', + 'contact', + 'faq', + 'post-types', + 'login-destination', +]; + +/** + * 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( () => { + return ( + wizardState.data.settings || { + homepage: { hasPage: true, pageId: null }, + about: { hasPage: true, pageId: null }, + contact: { hasPage: true, pageId: null }, + faq: { hasPage: true, pageId: null }, + 'post-types': { selectedTypes: [] }, + 'login-destination': { redirectOnLogin: false }, + } + ); + } ); + + 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 || [], + 'prpl-redirect-on-login': settings[ 'login-destination' ] + ?.redirectOnLogin + ? '1' + : '', + }, + } ); + } 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 } + +

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

+ { __( 'Post Types', 'progress-planner' ) } + + { currentSubStep + 1 }/{ SUB_STEPS.length } + +

+

+ { __( + 'Select which post types to include in your activity tracking.', + 'progress-planner' + ) } +

+
+ { postTypes.map( ( postType ) => ( + + ) ) } +
+
+ ); + + case 'login-destination': + return ( +
+

+ { __( 'Login Destination', 'progress-planner' ) } + + { currentSubStep + 1 }/{ SUB_STEPS.length } + +

+ +
+ ); + + default: + return null; + } + }; + + return ( + true }> +
+ { renderSubStep() } + { currentSubStep < SUB_STEPS.length - 1 && ( + + ) } +
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/steps/WelcomeStep.js b/assets/src/components/OnboardingWizard/steps/WelcomeStep.js new file mode 100644 index 0000000000..2023c0a8dc --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/WelcomeStep.js @@ -0,0 +1,227 @@ +/** + * WelcomeStep Component + * + * First step: Privacy policy acceptance and license generation. + * + * @package + */ + +import { useState, useEffect, Fragment } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import OnboardingStep from '../OnboardingStep'; +import { useLicenseGenerator } from '../../../hooks/useLicenseGenerator'; + +/** + * 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 + } ); + // Reload page to get new license state. + window.location.reload(); + return; + } catch ( error ) { + 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 ( + + { __( 'Start onboarding', 'progress-planner' ) } + + + } + buttonClass="prpl-btn-secondary" + > +
+
+
+
+

+ { __( + "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 && ( +
+ +
+ ) } + + { isGenerating && ( +
+ +
+ ) } +
+
+
+ +
+
+
+
+
+ ); +} diff --git a/assets/src/components/OnboardingWizard/steps/WhatsWhatStep.js b/assets/src/components/OnboardingWizard/steps/WhatsWhatStep.js new file mode 100644 index 0000000000..ff5a021ce5 --- /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.', + '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..c8848a3e4c --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/BadgesStep.test.js @@ -0,0 +1,274 @@ +/** + * 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 } +
+) ); + +// 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( 'does not render gauge when no badgeId', () => { + const propsWithoutBadge = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: {}, + }, + }; + + const { container } = render( + + ); + + expect( + container.querySelector( 'prpl-gauge' ) + ).not.toBeInTheDocument(); + } ); + + it( 'does not render gauge when no badgeName', () => { + const propsWithoutName = { + ...defaultProps, + stepData: { + id: 'onboarding-step-badges', + data: { + badgeId: 'monthly-2024-m12', + }, + }, + }; + + const { container } = render( + + ); + + expect( + container.querySelector( 'prpl-gauge' ) + ).not.toBeInTheDocument(); + } ); + + it( 'sets correct gauge attributes', () => { + const { container } = render( ); + + const gauge = container.querySelector( 'prpl-gauge' ); + expect( gauge ).toHaveAttribute( 'id', 'prpl-gauge-onboarding' ); + expect( gauge ).toHaveAttribute( 'data-max', '10' ); + expect( gauge ).toHaveAttribute( 'data-value', '0' ); + expect( gauge ).toHaveAttribute( + 'data-badge-id', + 'monthly-2024-m12' + ); + expect( gauge ).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 gauge + expect( screen.getByText( 'Monthly badge' ) ).toBeInTheDocument(); + expect( + container.querySelector( 'prpl-gauge' ) + ).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-gauge' ) + ).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..13a39ced75 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/FirstTaskStep.test.js @@ -0,0 +1,453 @@ +/** + * 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, + } ) ), +} ) ); + +// Mock OnboardingStep component +jest.mock( '../../OnboardingStep', () => ( props ) => ( +
+ + { props.children } +
+) ); + +// 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(); + jest.useFakeTimers(); + useTaskCompletion.mockReturnValue( { + completeTask: jest.fn().mockResolvedValue( {} ), + isCompleting: false, + } ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + describe( 'basic rendering', () => { + it( 'renders onboarding step wrapper', () => { + render( ); + + expect( + screen.getByTestId( '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 link', () => { + render( ); + + expect( + screen.getByRole( 'link', { name: 'Complete Task' } ) + ).toBeInTheDocument(); + } ); + + it( 'task link has correct href', () => { + render( ); + + const link = screen.getByRole( 'link', { name: 'Complete Task' } ); + expect( link ).toHaveAttribute( + 'href', + 'https://example.com/first-task' + ); + } ); + + it( 'task link opens in new tab', () => { + render( ); + + const link = screen.getByRole( 'link', { name: 'Complete Task' } ); + expect( link ).toHaveAttribute( 'target', '_blank' ); + expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' ); + } ); + + it( 'renders mark as complete button', () => { + render( ); + + expect( + screen.getByRole( 'button', { name: 'Mark as complete' } ) + ).toBeInTheDocument(); + } ); + + it( 'uses default action label when not provided', () => { + const taskWithoutLabel = { + ...mockTask, + action_label: undefined, + }; + + render( + + ); + + expect( + screen.getByRole( 'link', { name: 'Do it' } ) + ).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: 'Mark as complete', + } ); + 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: 'Mark as complete', + } ); + 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: 'Mark as complete', + } ); + await act( async () => { + fireEvent.click( button ); + } ); + + await act( async () => { + jest.advanceTimersByTime( 500 ); + } ); + + expect( onNext ).toHaveBeenCalled(); + } ); + + it( 'shows completing state', () => { + useTaskCompletion.mockReturnValue( { + completeTask: jest.fn(), + isCompleting: true, + } ); + + render( ); + + expect( + screen.getByRole( 'button', { name: 'Completing…' } ) + ).toBeInTheDocument(); + } ); + + it( 'disables button while completing', () => { + useTaskCompletion.mockReturnValue( { + completeTask: jest.fn(), + isCompleting: true, + } ); + + render( ); + + expect( + screen.getByRole( 'button', { name: 'Completing…' } ) + ).toBeDisabled(); + } ); + } ); + + describe( 'can proceed logic', () => { + it( 'cannot proceed when task not completed', () => { + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).toBeDisabled(); + } ); + + it( 'can proceed when task already completed in state', () => { + const propsWithCompleted = { + ...defaultProps, + wizardState: { + data: { + firstTaskCompleted: true, + }, + }, + }; + + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).not.toBeDisabled(); + } ); + } ); + + 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..feadbc0694 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/MoreTasksStep.test.js @@ -0,0 +1,391 @@ +/** + * Tests for MoreTasksStep Component + */ + +import { render, screen, fireEvent } from '@testing-library/react'; + +// Mock WordPress packages +jest.mock( '@wordpress/i18n', () => ( { + __: ( str ) => str, +} ) ); + +// Mock OnboardingStep component +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', task_title: 'Task 1' }, + { task_id: 'task-2', task_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="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 title', () => { + render( ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + screen.getByText( 'Complete more tasks' ) + ).toBeInTheDocument(); + } ); + + it( 'renders tasks from stepData', () => { + render( ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + expect( + screen.getByTestId( 'onboard-task-task-1' ) + ).toBeInTheDocument(); + expect( + screen.getByTestId( 'onboard-task-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(); + } ); + } ); + + describe( 'task completion', () => { + it( 'calls updateState when task completed', () => { + const updateState = jest.fn(); + + render( + + ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + const completeBtn = screen.getByTestId( 'complete-task-task-1' ); + fireEvent.click( completeBtn ); + + expect( updateState ).toHaveBeenCalledWith( + expect.objectContaining( { + data: expect.objectContaining( { + moreTasksCompleted: expect.objectContaining( { + 'task-1': true, + } ), + } ), + } ) + ); + } ); + } ); + + describe( 'finish onboarding', () => { + it( 'redirects when dashboard link clicked', () => { + render( ); + + const link = screen.getByText( 'Take me to the dashboard' ); + fireEvent.click( link ); + + expect( window.location.href ).toBe( + '/wp-admin/admin.php?page=progress-planner' + ); + } ); + + 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(); + } ); + } ); + + describe( 'can proceed logic', () => { + it( 'cannot proceed on intro sub-step', () => { + render( ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).toBeDisabled(); + } ); + + it( 'can proceed on tasks sub-step', () => { + render( ); + + const continueBtn = screen.getByText( /Let's tackle more tasks/ ); + fireEvent.click( continueBtn ); + + const nextBtn = screen.getByTestId( 'next-button' ); + expect( nextBtn ).not.toBeDisabled(); + } ); + } ); +} ); 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..7d24b9a33e --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/SettingsStep.test.js @@ -0,0 +1,362 @@ +/** + * 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' }, + }; + + 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', () => { + render( ); + + expect( screen.getByText( /1\/6/ ) ).toBeInTheDocument(); + } ); + + it( 'renders save button', () => { + render( ); + + expect( + screen.getByRole( 'button', { name: /Save & Continue/ } ) + ).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( + + ); + + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + + expect( + container.querySelector( '[data-page="about"]' ) + ).toBeInTheDocument(); + } ); + + it( 'updates progress indicator', async () => { + render( ); + + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + + expect( screen.getByText( /2\/6/ ) ).toBeInTheDocument(); + } ); + + it( 'advances through all page sub-steps', async () => { + const { container } = render( + + ); + + 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 ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + } + } ); + } ); + + describe( 'post-types sub-step', () => { + it( 'renders post types heading', async () => { + const { container } = render( + + ); + + // Navigate to post-types (5th sub-step) + for ( let i = 0; i < 4; i++ ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + + expect( + container.querySelector( '[data-page="post-types"]' ) + ).toBeInTheDocument(); + } ); + + it( 'renders post type checkboxes', async () => { + render( ); + + // Navigate to post-types + for ( let i = 0; i < 4; i++ ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + + expect( screen.getByLabelText( 'Posts' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Pages' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'login-destination sub-step', () => { + it( 'renders login destination checkbox', async () => { + const { container } = render( + + ); + + // Navigate to login-destination (6th sub-step) + for ( let i = 0; i < 5; i++ ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + + expect( + container.querySelector( '[data-page="login-destination"]' ) + ).toBeInTheDocument(); + } ); + + it( 'renders redirect checkbox', async () => { + render( ); + + // Navigate to login-destination + for ( let i = 0; i < 5; i++ ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + + expect( + screen.getByLabelText( /Redirect to Progress Planner/ ) + ).toBeInTheDocument(); + } ); + + it( 'hides save button on last sub-step', async () => { + render( ); + + // Navigate to login-destination + for ( let i = 0; i < 5; i++ ) { + const saveBtn = screen.getByRole( 'button', { + name: /Save & Continue/, + } ); + await act( async () => { + fireEvent.click( saveBtn ); + } ); + } + + expect( + screen.queryByRole( 'button', { name: /Save & Continue/ } ) + ).not.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..c3570cd7f2 --- /dev/null +++ b/assets/src/components/OnboardingWizard/steps/__tests__/WelcomeStep.test.js @@ -0,0 +1,376 @@ +/** + * 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 +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', () => { + render( ); + + expect( screen.getByTestId( 'next-button' ) ).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', () => { + render( ); + + const button = screen.getByTestId( 'next-button' ); + expect( button ).not.toBeDisabled(); + } ); + + it( 'calls onNext when button clicked with license', async () => { + const onNext = jest.fn(); + + render( ); + + const button = screen.getByTestId( 'next-button' ); + 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', () => { + render( ); + + const button = screen.getByTestId( 'next-button' ); + expect( button ).toBeDisabled(); + } ); + + it( 'can proceed after accepting privacy', () => { + render( ); + + const checkbox = screen.getByRole( 'checkbox' ); + fireEvent.click( checkbox ); + + const button = screen.getByTestId( 'next-button' ); + expect( button ).not.toBeDisabled(); + } ); + + 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, + } ); + + // Mock window.location.reload + const originalReload = window.location.reload; + delete window.location; + window.location = { reload: jest.fn() }; + + render( ); + + const button = screen.getByTestId( 'next-button' ); + await act( async () => { + fireEvent.click( button ); + } ); + + expect( mockGenerateLicense ).toHaveBeenCalledWith( { + 'with-email': 'no', + } ); + + // Restore + window.location.reload = originalReload; + } ); + + 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..70f546f0cf --- /dev/null +++ b/assets/src/components/Popovers/AIOSEOPopover.js @@ -0,0 +1,191 @@ +/** + * 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 { useState, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import { submitPluginSettings } from '../../hooks/usePopoverForms'; + +export default function AIOSEOPopover( { task, onSubmit, onClose } ) { + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + + /** + * Handle form submission. + */ + const handleSubmit = useCallback( + async ( e ) => { + e.preventDefault(); + + setIsLoading( true ); + setError( null ); + + try { + 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 ); + } + } catch ( err ) { + setError( + __( + 'Something went wrong. Please try again.', + 'progress-planner' + ) + ); + } finally { + setIsLoading( false ); + } + }, + [ task, onSubmit ] + ); + + const taskTitle = task.title?.rendered || task.title; + const taskDescription = + task.description?.rendered || task.description || ''; + + return ( + +
+

{ taskTitle }

+ { taskDescription &&

{ taskDescription }

} +
+
+
+ { error && ( +

+ { error } +

+ ) } +
+ +
+
+
+
+ ); +} diff --git a/assets/src/components/Popovers/BadgeStreakPopover.js b/assets/src/components/Popovers/BadgeStreakPopover.js new file mode 100644 index 0000000000..a254d1372d --- /dev/null +++ b/assets/src/components/Popovers/BadgeStreakPopover.js @@ -0,0 +1,256 @@ +/** + * 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.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 { __, sprintf } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import { resolveTaskId } from '../../utils/taskIdResolver'; + +export default function BadgeStreakPopover( { task, onClose } ) { + const [ badgeStats, setBadgeStats ] = useState( null ); + const [ isLoading, setIsLoading ] = useState( true ); + + /** + * Load badge stats from REST API. + */ + useEffect( () => { + apiFetch( { path: '/progress-planner/v1/badge-stats' } ) + .then( ( response ) => { + setBadgeStats( response.badges || {} ); + } ) + .catch( () => { + setBadgeStats( {} ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + }, [] ); + + /** + * 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 ? ( +

{ __( 'Loading…', 'progress-planner' ) }

+ ) : ( +
+ { 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 ? ( +

{ __( 'Loading…', 'progress-planner' ) }

+ ) : ( +
+ { 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..6f56fbf563 --- /dev/null +++ b/assets/src/components/Popovers/BlogDescriptionPopover.js @@ -0,0 +1,143 @@ +/** + * 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, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; +import { submitSiteSettings } from '../../hooks/usePopoverForms'; + +export default function BlogDescriptionPopover( { task, onSubmit, onClose } ) { + const [ value, setValue ] = useState( '' ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState( null ); + + /** + * Load current blog description on mount. + */ + useEffect( () => { + apiFetch( { path: '/wp/v2/settings' } ) + .then( ( settings ) => { + setValue( settings.description || '' ); + } ) + .catch( () => { + // Ignore errors, use empty string + } ); + }, [] ); + + /** + * Handle form submission. + */ + const handleSubmit = useCallback( + async ( e ) => { + e.preventDefault(); + + if ( ! value.trim() ) { + return; + } + + setIsLoading( true ); + setError( null ); + + try { + 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 ); + } + } catch ( err ) { + setError( + __( + 'Something went wrong. Please try again.', + 'progress-planner' + ) + ); + } finally { + setIsLoading( false ); + } + }, + [ value, task, onSubmit ] + ); + + const taskTitle = 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 }

} + + { error && ( +

+ { error } +

+ ) } +
+ +
+
+
+
+ ); +} diff --git a/assets/src/components/Popovers/CustomPopover.js b/assets/src/components/Popovers/CustomPopover.js new file mode 100644 index 0000000000..2e3b206167 --- /dev/null +++ b/assets/src/components/Popovers/CustomPopover.js @@ -0,0 +1,328 @@ +/** + * 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 apiFetch from '@wordpress/api-fetch'; +import InteractiveTaskPopover from './InteractiveTaskPopover'; + +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 = + window.prplDashboardConfig?.ajaxUrl || + window.progressPlanner?.ajaxUrl || + '/wp-admin/admin-ajax.php'; + const nonce = + window.prplDashboardConfig?.nonce || + window.progressPlanner?.nonce || + ''; + + 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 = + window.prplDashboardConfig?.ajaxUrl || + window.progressPlanner?.ajaxUrl || + '/wp-admin/admin-ajax.php'; + const nonce = + window.prplDashboardConfig?.nonce || + window.progressPlanner?.nonce || + ''; + + 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 = 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 } +

+ ) } +