diff --git a/.distignore b/.distignore
index 9a5cba0e4a..446c22cc39 100644
--- a/.distignore
+++ b/.distignore
@@ -1,6 +1,9 @@
-/.wordpress-org
/.git
/.github
+/.husky
+/.vscode
+/.wordpress-org
+/bin
/coverage
/tests
/vendor
@@ -8,14 +11,18 @@
.editorconfig
.eslintrc.js
.env.example
-.gitignore
+.eslintcrc.js
.gitattributes
+.gitignore
.php-cs-fixer.dist.php
+.stylelintrc.json
composer.json
composer.lock
-package.json
package-lock.json
+package.json
phpcs.xml.dist
phpstan.neon.dist
phpunit.xml.dist
+playwright.config.js
README.md
+SECURITY.md
diff --git a/.gitattributes b/.gitattributes
index 313f925cd5..c5bc96da13 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,16 +1,25 @@
-.editorconfig export-ignore
-.gitattributes export-ignore
.github/* export-ignore
+.husky/* export-ignore
+.vscode/* export-ignore
+.wordpress-org/* export-ignore
+bin/* export-ignore
+coverage/* export-ignore
tests/* export-ignore
+.distignore export-ignore
+.editorconfig export-ignore
+.env.example export-ignore
+.eslintcrc.js export-ignore
+.gitattributes export-ignore
.gitignore export-ignore
+.php-cs-fixer.dist.php export-ignore
+.stylelintrc.json export-ignore
composer.json export-ignore
composer.lock export-ignore
package.json export-ignore
package-lock.json export-ignore
-.wordpress-org/* export-ignore
phpcs.xml.dist export-ignore
phpstan.neon.dist export-ignore
phpunit.xml.dist export-ignore
+playwright.config.js export-ignore
README.md export-ignore
-.wordpress-org/* export-ignore
-.vscode/* export-ignore
+SECURITY.md export-ignore
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
new file mode 100644
index 0000000000..306f87d6ed
--- /dev/null
+++ b/.github/workflows/code-coverage.yml
@@ -0,0 +1,509 @@
+name: Code Coverage
+
+on:
+ pull_request:
+ push:
+ branches:
+ - develop
+ - main
+ workflow_dispatch:
+
+# Cancels all previous workflow runs for the same branch that have not yet completed.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ code-coverage:
+ name: Code Coverage Check
+ runs-on: ubuntu-latest
+
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: false
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: wordpress_tests
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Fetch all history for accurate diff
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ ini-values: zend.assertions=1, error_reporting=-1, display_errors=On, memory_limit=512M
+ coverage: xdebug # Use Xdebug for coverage (PCOV was causing PHP to crash)
+ tools: composer
+
+ - name: Install SVN and XML tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y subversion libxml2-utils
+
+ - name: Install Composer dependencies
+ uses: ramsey/composer-install@v2
+ with:
+ dependency-versions: "highest"
+ composer-options: "--prefer-dist --with-dependencies"
+ custom-cache-suffix: $(date -u -d "-0 month -$(($(date +%d)-1)) days" "+%F")-codecov-v2
+
+ - name: Install WordPress Test Suite
+ shell: bash
+ run: tests/bin/install-wp-tests.sh wordpress_tests root root 127.0.0.1:3306 latest
+
+ - name: Generate code coverage report for current branch
+ run: |
+ echo "=== Debug: PHP Configuration ==="
+ php -i | grep -E "(memory_limit|max_execution_time|xdebug)"
+ echo "=== Debug: Check Xdebug is loaded ==="
+ php -m | grep xdebug || echo "Xdebug not loaded"
+ php -r "var_dump(extension_loaded('xdebug'));"
+ echo "=== Running PHPUnit with coverage ==="
+ echo "Start time: $(date)"
+ echo "Memory before: $(free -h | grep Mem)"
+
+ # Run PHPUnit with coverage - allow test failures but ensure coverage is generated
+ # test-class-security.php is excluded via phpunit.xml.dist to avoid output contamination
+ set +e
+ # Run PHPUnit and capture both test output and coverage text separately
+ php -d memory_limit=512M -d max_execution_time=300 \
+ vendor/bin/phpunit --configuration phpunit.xml.dist \
+ --coverage-clover=coverage.xml \
+ --coverage-text --colors=never > phpunit-with-coverage.log 2>&1
+ PHPUNIT_EXIT=$?
+ set -e
+
+ # Extract test output (everything before coverage section) for debugging
+ # Coverage section typically starts with a line like "Code Coverage Report:" or summary table
+ # Extract everything up to (but not including) the coverage section
+ awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{exit} {print}' phpunit-with-coverage.log > phpunit-output.log || cat phpunit-with-coverage.log > phpunit-output.log
+
+ # Extract coverage text output (the coverage section)
+ # Coverage section starts with summary or "Code Coverage Report"
+ awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{flag=1} flag' phpunit-with-coverage.log > current-coverage-full.txt || tail -200 phpunit-with-coverage.log > current-coverage-full.txt
+
+ echo "End time: $(date)"
+ echo "Memory after: $(free -h | grep Mem)"
+ echo "=== Debug: PHPUnit exit code: $PHPUNIT_EXIT ==="
+ echo "=== Note: Exit code $PHPUNIT_EXIT (0=success, 1=test failures, 2=errors, >128=signal termination) ==="
+ echo "=== Debug: Line count of PHPUnit output ==="
+ wc -l phpunit-output.log
+ echo "=== Debug: Last 100 lines of PHPUnit output ==="
+ tail -100 phpunit-output.log
+ echo "=== Debug: After running PHPUnit ==="
+ ls -la coverage* 2>/dev/null || echo "No coverage files in current directory"
+ echo "=== Checking if coverage report was generated ==="
+ if [ -f coverage.xml ]; then
+ echo "SUCCESS: coverage.xml exists!"
+ ls -lh coverage.xml
+ echo "First 20 lines of coverage.xml:"
+ head -20 coverage.xml
+ else
+ echo "FAIL: coverage.xml was not generated"
+ echo "=== Checking for errors in PHPUnit output ==="
+ grep -i "error\|fatal\|exception\|segfault\|out of memory" phpunit-output.log || echo "No obvious errors found"
+ # Exit with error if coverage wasn't generated
+ exit 1
+ fi
+ continue-on-error: false
+
+ - name: Upload PHPUnit output for debugging
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: phpunit-output
+ path: phpunit-output.log
+ retention-days: 7
+
+ - name: Generate coverage report summary
+ id: coverage
+ run: |
+ # Extract overall coverage from coverage.xml (Clover format)
+ # This avoids running PHPUnit twice - we already have coverage.xml from the first run
+ if [ -f coverage.xml ]; then
+ # Extract metrics from Clover XML using xmllint
+ # Fallback to Python if xmllint fails
+ STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
+ COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
+
+ # Calculate coverage percentage
+ if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then
+ COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc)
+ else
+ COVERAGE="0"
+ fi
+
+ echo "current_coverage=$COVERAGE" >> $GITHUB_OUTPUT
+ echo "Current code coverage: $COVERAGE% (from coverage.xml)"
+ echo "Statements: $COVERED_STATEMENTS / $STATEMENTS"
+ else
+ echo "ERROR: coverage.xml not found!"
+ echo "current_coverage=0" >> $GITHUB_OUTPUT
+ exit 1
+ fi
+
+ # Coverage text output was already extracted from phpunit-with-coverage.log in the previous step
+ # If extraction failed, try to generate it again as fallback
+ if [ ! -s current-coverage-full.txt ]; then
+ echo "Warning: Could not extract coverage text from phpunit-with-coverage.log, generating separately..."
+ vendor/bin/phpunit --configuration phpunit.xml.dist --coverage-text --colors=never > current-coverage-full.txt 2>&1 || true
+ fi
+
+ # Save detailed per-file coverage for later comparison
+ # PHPUnit outputs class name on one line, stats on the next line
+ # We need to combine them: "ClassName" + " Methods: X% Lines: Y%"
+ awk '
+ /^[A-Za-z_]/ { classname = $0; next }
+ /^ Methods:.*Lines:/ {
+ gsub(/\x1b\[[0-9;]*m/, "", classname);
+ gsub(/\x1b\[[0-9;]*m/, "", $0);
+ print classname " " $0
+ }
+ ' current-coverage-full.txt > current-coverage-details.txt || true
+
+ echo "=== Current coverage details saved ==="
+ head -20 current-coverage-details.txt || true
+
+ - name: Checkout base branch for comparison
+ if: github.event_name == 'pull_request'
+ run: |
+ # Save current branch coverage files
+ cp current-coverage-details.txt /tmp/current-coverage-details.txt 2>/dev/null || true
+ cp current-coverage-full.txt /tmp/current-coverage-full.txt 2>/dev/null || true
+
+ # Stash any local changes (like composer.lock)
+ git stash --include-untracked || true
+ git fetch origin ${{ github.base_ref }}
+ git checkout origin/${{ github.base_ref }}
+
+ - name: Install dependencies on base branch
+ if: github.event_name == 'pull_request'
+ run: |
+ composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Generate coverage report for base branch
+ if: github.event_name == 'pull_request'
+ id: base_coverage
+ run: |
+ # Generate coverage for base branch (including coverage.xml)
+ vendor/bin/phpunit --configuration phpunit.xml.dist \
+ --coverage-clover=base-coverage.xml \
+ --coverage-text --colors=never > base-coverage-full.txt 2>&1 || true
+
+ # Extract overall coverage from base-coverage.xml (Clover format)
+ if [ -f base-coverage.xml ]; then
+ # Extract metrics from Clover XML using xmllint
+ # Fallback to Python if xmllint fails
+ STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
+ COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0")
+
+ # Calculate coverage percentage
+ if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then
+ BASE_COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc)
+ else
+ BASE_COVERAGE="0"
+ fi
+
+ echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT
+ echo "Base branch code coverage: $BASE_COVERAGE% (from base-coverage.xml)"
+ echo "Statements: $COVERED_STATEMENTS / $STATEMENTS"
+ else
+ # Fallback to text extraction if XML not available
+ BASE_COVERAGE=$(grep "^ Lines:" base-coverage-full.txt | tail -1 | awk '{print $2}' | sed 's/%//' || echo "0")
+ echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT
+ echo "Base branch code coverage: $BASE_COVERAGE% (from text fallback)"
+ fi
+
+ # Extract per-file coverage for comparison
+ # PHPUnit outputs class name on one line, stats on the next line
+ awk '
+ /^[A-Za-z_]/ { classname = $0; next }
+ /^ Methods:.*Lines:/ {
+ gsub(/\x1b\[[0-9;]*m/, "", classname);
+ gsub(/\x1b\[[0-9;]*m/, "", $0);
+ print classname " " $0
+ }
+ ' base-coverage-full.txt > base-coverage-details.txt || true
+
+ echo "=== Base coverage details saved ==="
+ head -20 base-coverage-details.txt || true
+ continue-on-error: true
+
+ - name: Generate coverage diff report
+ if: github.event_name == 'pull_request'
+ id: coverage_diff
+ run: |
+ # Restore current branch coverage files
+ cp /tmp/current-coverage-details.txt current-coverage-details.txt 2>/dev/null || true
+
+ # Create a Python script to compare coverage
+ cat > compare_coverage.py << 'PYTHON_SCRIPT'
+ import re
+ import sys
+ import json
+
+ def parse_coverage_line(line):
+ """Parse a coverage line to extract class name and line coverage percentage."""
+ # Example line: "Progress_Planner\Activity Methods: 55.56% ( 5/ 9) Lines: 91.92% ( 91/ 99)"
+ match = re.search(r'^([\w\\]+)\s+Methods:\s+([\d.]+)%.*Lines:\s+([\d.]+)%\s+\(\s*(\d+)/\s*(\d+)\)', line)
+ if match:
+ class_name = match.group(1)
+ # Group 2 is methods percentage (not used)
+ line_percent = float(match.group(3)) # Lines percentage
+ covered_lines = int(match.group(4)) # Covered lines count
+ total_lines = int(match.group(5)) # Total lines count
+ return class_name, line_percent, covered_lines, total_lines
+ return None, None, None, None
+
+ def load_coverage(filename):
+ """Load coverage data from file."""
+ coverage = {}
+ try:
+ with open(filename, 'r') as f:
+ for line in f:
+ class_name, percent, covered, total = parse_coverage_line(line)
+ if class_name:
+ coverage[class_name] = {
+ 'percent': percent,
+ 'covered': covered,
+ 'total': total
+ }
+ except FileNotFoundError:
+ pass
+ return coverage
+
+ # Load current and base coverage
+ current = load_coverage('current-coverage-details.txt')
+ base = load_coverage('base-coverage-details.txt')
+
+ # Find changes
+ changes = {
+ 'new_files': [],
+ 'improved': [],
+ 'degraded': [],
+ 'unchanged': []
+ }
+
+ # Check all current files
+ for class_name in sorted(current.keys()):
+ curr_data = current[class_name]
+ if class_name not in base:
+ # New file
+ changes['new_files'].append({
+ 'class': class_name,
+ 'coverage': curr_data['percent'],
+ 'lines': f"{curr_data['covered']}/{curr_data['total']}"
+ })
+ else:
+ base_data = base[class_name]
+ diff = curr_data['percent'] - base_data['percent']
+ if abs(diff) < 0.01: # Less than 0.01% difference
+ continue # Skip unchanged files for brevity
+ elif diff > 0:
+ changes['improved'].append({
+ 'class': class_name,
+ 'old': base_data['percent'],
+ 'new': curr_data['percent'],
+ 'diff': diff
+ })
+ else:
+ changes['degraded'].append({
+ 'class': class_name,
+ 'old': base_data['percent'],
+ 'new': curr_data['percent'],
+ 'diff': diff
+ })
+
+ # Output as JSON for GitHub Actions
+ print(json.dumps(changes))
+ PYTHON_SCRIPT
+
+ # Run the comparison
+ CHANGES_JSON=$(python3 compare_coverage.py)
+ echo "coverage_changes<> $GITHUB_OUTPUT
+ echo "$CHANGES_JSON" >> $GITHUB_OUTPUT
+ echo "EOF" >> $GITHUB_OUTPUT
+
+ echo "=== Coverage changes ==="
+ echo "$CHANGES_JSON" | python3 -m json.tool || echo "$CHANGES_JSON"
+ continue-on-error: true
+
+ - name: Compare coverage and enforce threshold
+ if: github.event_name == 'pull_request'
+ run: |
+ CURRENT="${{ steps.coverage.outputs.current_coverage }}"
+ BASE="${{ steps.base_coverage.outputs.base_coverage }}"
+
+ # Default to 0 if base coverage couldn't be determined
+ BASE=${BASE:-0}
+
+ echo "Current Coverage: $CURRENT%"
+ echo "Base Coverage: $BASE%"
+
+ # Calculate the difference
+ DIFF=$(echo "$CURRENT - $BASE" | bc)
+ echo "Coverage Difference: $DIFF%"
+
+ # Check if coverage dropped by more than 0.5%
+ THRESHOLD=-0.5
+ if (( $(echo "$DIFF < $THRESHOLD" | bc -l) )); then
+ echo "β Code coverage dropped by ${DIFF}%, which exceeds the allowed threshold of ${THRESHOLD}%"
+ echo "Please add tests to maintain or improve code coverage."
+ exit 1
+ else
+ echo "β
Code coverage check passed!"
+ echo "Coverage change: ${DIFF}%"
+ fi
+
+ - name: Comment PR with coverage
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ env:
+ COVERAGE_CHANGES: ${{ steps.coverage_diff.outputs.coverage_changes }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ 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);
+ const diffEmoji = diff >= 0 ? 'π' : 'π';
+ const coverageEmoji = current >= 80 ? 'π' : current >= 60 ? 'π' : current >= 40 ? 'π' : 'π';
+ const status = diff >= -0.5 ? 'β
' : 'β οΈ';
+
+ // Parse coverage changes JSON from environment variable
+ let changesJson = {};
+ try {
+ const changesStr = process.env.COVERAGE_CHANGES || '{}';
+ changesJson = JSON.parse(changesStr);
+ } catch (e) {
+ console.log('Failed to parse coverage changes:', e);
+ console.log('Raw value:', process.env.COVERAGE_CHANGES);
+ }
+
+ // Build detailed changes section
+ let detailedChanges = '';
+ let hasChanges = false;
+
+ // Build inner content for details
+ let changesContent = '';
+
+ // New files with coverage
+ if (changesJson.new_files && changesJson.new_files.length > 0) {
+ hasChanges = true;
+ changesContent += '\n### π New Files\n\n';
+ changesContent += '| Class | Coverage | Lines |\n';
+ changesContent += '|-------|----------|-------|\n';
+ for (const file of changesJson.new_files) {
+ const emoji = file.coverage >= 80 ? 'π’' : file.coverage >= 60 ? 'π‘' : 'π΄';
+ changesContent += `| ${emoji} \`${file.class}\` | ${file.coverage.toFixed(2)}% | ${file.lines} |\n`;
+ }
+ }
+
+ // Improved coverage
+ if (changesJson.improved && changesJson.improved.length > 0) {
+ hasChanges = true;
+ changesContent += '\n### π Coverage Improved\n\n';
+ changesContent += '| Class | Before | After | Change |\n';
+ changesContent += '|-------|--------|-------|--------|\n';
+ const sortedImproved = changesJson.improved.sort((a, b) => b.diff - a.diff);
+ for (const file of sortedImproved) {
+ changesContent += `| \`${file.class}\` | ${file.old.toFixed(2)}% | ${file.new.toFixed(2)}% | +${file.diff.toFixed(2)}% |\n`;
+ }
+ }
+
+ // Degraded coverage
+ if (changesJson.degraded && changesJson.degraded.length > 0) {
+ hasChanges = true;
+ changesContent += '\n### π Coverage Decreased\n\n';
+ changesContent += '| Class | Before | After | Change |\n';
+ changesContent += '|-------|--------|-------|--------|\n';
+ const sortedDegraded = changesJson.degraded.sort((a, b) => a.diff - b.diff);
+ for (const file of sortedDegraded) {
+ changesContent += `| \`${file.class}\` | ${file.old.toFixed(2)}% | ${file.new.toFixed(2)}% | ${file.diff.toFixed(2)}% |\n`;
+ }
+ }
+
+ // Wrap in collapsible details if there are changes
+ if (hasChanges) {
+ const totalFiles = (changesJson.new_files?.length || 0) +
+ (changesJson.improved?.length || 0) +
+ (changesJson.degraded?.length || 0);
+ detailedChanges = `\n\nπ File-level Coverage Changes (${totalFiles} files) \n${changesContent}\n \n`;
+ }
+
+ const comment = `## ${status} Code Coverage Report
+
+ | Metric | Value |
+ |--------|-------|
+ | **Total Coverage** | **${current.toFixed(2)}%** ${coverageEmoji} |
+ | Base Coverage | ${base.toFixed(2)}% |
+ | Difference | ${diffEmoji} **${diff}%** |
+
+ ${current >= 40 ? 'β
Coverage meets minimum threshold (40%)' : 'β οΈ Coverage below recommended 40% threshold'}
+
+ ${diff < -0.5 ? 'β οΈ **Warning:** Coverage dropped by more than 0.5%. Please add tests.' : ''}
+ ${diff >= 0 ? 'π Great job maintaining/improving code coverage!' : ''}
+
+ ${detailedChanges}
+
+
+ βΉοΈ About this report
+
+ - All tests run in a single job with Xdebug coverage
+ - Security tests excluded from coverage to prevent output issues
+ - Coverage calculated from line coverage percentages
+
+
+ `;
+
+ // Find existing coverage report comment
+ const {data: comments} = await github.rest.issues.listComments({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+
+ const botComment = comments.find(comment =>
+ comment.user.type === 'Bot' &&
+ comment.body.includes('Code Coverage Report')
+ );
+
+ if (botComment) {
+ // Update existing comment
+ await github.rest.issues.updateComment({
+ comment_id: botComment.id,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+ } else {
+ // Create new comment
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: comment
+ });
+ }
+
+ - name: Generate HTML coverage report
+ if: always()
+ run: |
+ vendor/bin/phpunit --coverage-html=coverage-html
+ continue-on-error: true
+
+ - name: Upload HTML coverage report as artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage-html/
+ retention-days: 30
diff --git a/.github/workflows/coverage-status-check.yml b/.github/workflows/coverage-status-check.yml
new file mode 100644
index 0000000000..af3a7527d1
--- /dev/null
+++ b/.github/workflows/coverage-status-check.yml
@@ -0,0 +1,54 @@
+name: Coverage Status Check - DISABLED
+
+on:
+ workflow_dispatch:
+
+jobs:
+ coverage-gate:
+ name: Coverage Gate
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Wait for coverage check
+ timeout-minutes: 10
+ run: |
+ echo "Waiting for Code Coverage Check to complete..."
+
+ # Wait up to 10 minutes for the check to appear and complete
+ for i in {1..60}; do
+ # Get the status of the Code Coverage Check
+ STATUS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs \
+ --jq '.check_runs[] | select(.name == "Code Coverage Check") | .status' || echo "")
+
+ CONCLUSION=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs \
+ --jq '.check_runs[] | select(.name == "Code Coverage Check") | .conclusion' || echo "")
+
+ if [ -n "$STATUS" ]; then
+ echo "Check found with status: $STATUS, conclusion: $CONCLUSION"
+
+ if [ "$STATUS" == "completed" ]; then
+ if [ "$CONCLUSION" == "success" ]; then
+ echo "β
Code coverage check passed!"
+ exit 0
+ else
+ echo "β Code coverage check failed with conclusion: $CONCLUSION"
+ exit 1
+ fi
+ fi
+ else
+ echo "Check not found yet (attempt $i/60)"
+ fi
+
+ sleep 10
+ done
+
+ echo "β Timeout waiting for Code Coverage Check"
+ exit 1
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Coverage gate passed
+ run: echo "β
Code coverage requirements met!"
diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml
index e78f6f5c9d..f272aec2fa 100644
--- a/.github/workflows/cs.yml
+++ b/.github/workflows/cs.yml
@@ -3,6 +3,11 @@ name: CS
on:
# Run on all relevant pushes (except to main) and on all relevant pull requests.
push:
+ branches:
+ - main
+ - develop
+ - 'release/[0-9]+.[0-9]+*'
+ - 'hotfix/[0-9]+.[0-9]+*'
paths:
- '**.php'
- 'composer.json'
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index c3b8373a8d..2ce19b0f37 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -3,6 +3,11 @@ name: Test
on:
# Run on pushes to select branches and on all pull requests.
push:
+ branches:
+ - main
+ - develop
+ - 'release/[0-9]+.[0-9]+*'
+ - 'hotfix/[0-9]+.[0-9]+*'
pull_request:
# Allow manually triggering the workflow.
workflow_dispatch:
@@ -21,7 +26,7 @@ jobs:
matrix:
include:
- php_version: '8.2'
- wp_version: '6.2'
+ wp_version: '6.7'
multisite: false
- php_version: '8.2'
diff --git a/.github/workflows/playground-merged.yml b/.github/workflows/playground-merged.yml
index d1f46a6970..0a129e50ac 100644
--- a/.github/workflows/playground-merged.yml
+++ b/.github/workflows/playground-merged.yml
@@ -14,6 +14,8 @@ jobs:
pull-requests: write
actions: read
steps:
+ - uses: actions/checkout@v4
+
- name: Prepare blueprint with artifact link
id: blueprint
run: |
diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml
index 714bee2a9c..cdfc5863c9 100644
--- a/.github/workflows/playground.yml
+++ b/.github/workflows/playground.yml
@@ -12,7 +12,7 @@ jobs:
actions: read
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
# Prepare a folder named exactly like the repo as the plugin root.
# If the repo already has such a folder (common for WP plugins), use it.
@@ -45,9 +45,30 @@ jobs:
PLUGIN_FILE="${{ steps.prep.outputs.PKG_DIR }}/${{ github.event.repository.name }}.php"
PR_NUMBER="${{ github.event.number }}"
- # Extract current version and add PR number
+ # Extract current version
CURRENT_VERSION=$(grep -o "Version:[[:space:]]*[0-9.]*" "$PLUGIN_FILE" | sed 's/Version:[[:space:]]*//')
- NEW_VERSION="${CURRENT_VERSION} - PR ${PR_NUMBER}"
+
+ # Increment patch version if it exists, otherwise increment minor version
+ # Handle versions like 2.1.5 or 2.1
+ if [[ "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then
+ MAJOR="${BASH_REMATCH[1]}"
+ MINOR="${BASH_REMATCH[2]}"
+ PATCH="${BASH_REMATCH[4]}"
+
+ # Build new version with 'b' suffix
+ if [ -n "$PATCH" ]; then
+ # If patch exists, increment patch by 1
+ PATCH=$((PATCH + 1))
+ NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}b - PR ${PR_NUMBER}"
+ else
+ # If no patch, increment minor by 1
+ MINOR=$((MINOR + 1))
+ NEW_VERSION="${MAJOR}.${MINOR}b - PR ${PR_NUMBER}"
+ fi
+ else
+ # Fallback: if version format is unexpected, just add .1 and 'b'
+ NEW_VERSION="${CURRENT_VERSION}.1b - PR ${PR_NUMBER}"
+ fi
# Replace the version line
sed -i "s/Version:[[:space:]]*[0-9.]*/Version: ${NEW_VERSION}/" "$PLUGIN_FILE"
@@ -76,7 +97,7 @@ jobs:
# Upload the FOLDER (not a .zip). The artifact service zips it for us,
# keeping the top-level folder name inside the archive.
- name: Upload plugin artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: ${{ github.event.repository.name }}
path: ${{ steps.prep.outputs.PKG_DIR }}
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 09995e802b..49cdf31381 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -16,7 +16,10 @@ env:
on:
push:
branches:
+ - main
- develop
+ - 'release/[0-9]+.[0-9]+*'
+ - 'hotfix/[0-9]+.[0-9]+*'
pull_request:
jobs:
diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml
new file mode 100644
index 0000000000..fbac09a930
--- /dev/null
+++ b/.github/workflows/plugin-check.yml
@@ -0,0 +1,33 @@
+name: 'WordPress.org Plugin Check'
+on: # rebuild any PRs and main branch changes
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: none
+ tools: wp-cli
+
+ - name: Install latest version of dist-archive-command
+ run: wp package install wp-cli/dist-archive-command:@stable
+
+ - name: Build plugin
+ run: |
+ wp dist-archive . ./${{ github.event.repository.name }}.zip
+ mkdir build
+ unzip ${{ github.event.repository.name }}.zip -d build
+
+ - name: Run plugin check
+ uses: wordpress/plugin-check-action@v1.1.4
+ with:
+ build-dir: './build/${{ github.event.repository.name }}'
+ exclude-checks: |
+ direct_file_access
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index a57a11f9e3..5f31f7d22a 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -3,6 +3,11 @@ name: Security
on:
# Run on all pushes and on all pull requests.
push:
+ branches:
+ - main
+ - develop
+ - 'release/[0-9]+.[0-9]+*'
+ - 'hotfix/[0-9]+.[0-9]+*'
pull_request:
# Also run this workflow every Monday at 6:00.
schedule:
diff --git a/.github/workflows/upgrade-compat.yml b/.github/workflows/upgrade-compat.yml
index 595ef186d4..8607f632e5 100644
--- a/.github/workflows/upgrade-compat.yml
+++ b/.github/workflows/upgrade-compat.yml
@@ -16,7 +16,10 @@ env:
on:
push:
branches:
+ - main
- develop
+ - 'release/[0-9]+.[0-9]+*'
+ - 'hotfix/[0-9]+.[0-9]+*'
pull_request:
# Allow manually triggering the workflow.
workflow_dispatch:
@@ -135,4 +138,4 @@ jobs:
docker exec $WP_CONTAINER wp plugin activate wordpress-seo-premium --allow-root
# Show plugin settings
- docker exec $WP_CONTAINER wp option get progress_planner_settings --allow-root
\ No newline at end of file
+ docker exec $WP_CONTAINER wp option get progress_planner_settings --allow-root
diff --git a/.gitignore b/.gitignore
index 83c571da28..47ae5d782f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,4 +12,5 @@ playwright/.cache/
auth.json
# Environment variables
-.env
\ No newline at end of file
+.env
+coverage/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f72e8b67b..d38edb7d44 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+= 1.10.0 =
+
+Added these recommendations from Ravi:
+
+* Reduce number of autoloaded options
+
= 1.9.0 =
In this release we've added an integration with the **All In One Seo** plugin so youβll now see personalized suggestions based on your current SEO configuration.
diff --git a/README.md b/README.md
index 0172747937..6b6e782e52 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
[](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpunit.yml)
+[](https://github.com/ProgressPlanner/progress-planner/actions/workflows/code-coverage.yml)
[](https://github.com/ProgressPlanner/progress-planner/actions/workflows/cs.yml)
[](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpstan.yml)
[](https://github.com/ProgressPlanner/progress-planner/actions/workflows/lint.yml)
@@ -24,6 +25,39 @@ This post explains what Progress Planner does and how to use it: [What does Prog
You can find [installation instructions here](https://prpl.fyi/install).
+## Contributing
+
+### Running Tests
+
+To run the test suite:
+
+```bash
+composer test
+```
+
+### Code Coverage
+
+To generate code coverage reports locally, you need either [PCOV](https://pecl.php.net/package/PCOV) (recommended) or [Xdebug](https://xdebug.org/) installed:
+
+```bash
+composer coverage
+```
+
+This will generate:
+- An HTML coverage report in the `coverage-html/` directory
+- A text-based coverage summary in your terminal
+
+**Coverage Requirements:** Pull requests must maintain code coverage within 0.5% of the base branch. PRs that drop coverage by more than 0.5% will be blocked until additional tests are added.
+
+### Other Quality Commands
+
+```bash
+composer check-cs # Check coding standards
+composer fix-cs # Auto-fix coding standards
+composer phpstan # Run static analysis
+composer lint # Check PHP syntax
+```
+
## Branches on this repository
We use a couple of branches in this repository to keep things clean:
diff --git a/assets/css/admin.css b/assets/css/admin.css
index 192f8d591b..09733f4e85 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -80,6 +80,24 @@ body.toplevel_page_progress-planner {
margin-top: var(--prpl-padding);
}
+/*------------------------------------*\
+ Styles for the container of the page when the privacy policy is not accepted.
+\*------------------------------------*/
+.prpl-pp-not-accepted {
+
+ .prpl-start-onboarding-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--prpl-padding);
+ }
+
+ .prpl-start-onboarding-graphic {
+ width: 250px;
+ }
+}
+
/*------------------------------------*\
Generic styles.
\*------------------------------------*/
diff --git a/assets/css/onboard.css b/assets/css/onboard.css
deleted file mode 100644
index bac24ea7dd..0000000000
--- a/assets/css/onboard.css
+++ /dev/null
@@ -1,23 +0,0 @@
-#prpl-onboarding-form .prpl-form-fields label {
- display: grid;
- grid-template-columns: 1fr 3fr;
- margin-bottom: 0.5em;
- gap: var(--prpl-padding);
-}
-
-#prpl-onboarding-form label > span:has(input[type="checkbox"]) {
- display: flex;
- align-items: baseline;
-}
-
-.prpl-onboard-form-radio-select {
-
- label {
- display: block !important;
- }
-}
-
-#prpl-onboarding-submit-wrapper {
- display: flex;
- align-items: center;
-}
diff --git a/assets/css/onboarding/onboarding.css b/assets/css/onboarding/onboarding.css
new file mode 100644
index 0000000000..c3362563dd
--- /dev/null
+++ b/assets/css/onboarding/onboarding.css
@@ -0,0 +1,1353 @@
+.prpl-popover-onboarding {
+
+ --prpl-color-text: #4b5563;
+
+ /* Paper */
+ --prpl-onboarding-popover-background: var(--prpl-background-paper, #fff);
+
+ /* Steps navigation */
+ --prpl-background-steps: var(--prpl-background, #f6f7f9);
+ --prpl-background-info: var(--prpl-background-content, #f6f5fb);
+ --prpl-background-step-active: var(--prpl-graph-color-4, #534786);
+ --prpl-color-number-step-active: var(--prpl-background-paper, #fff);
+ --prpl-color-ui-icon: #6b7280; /* already exists in variables-color.css */
+ --prpl-color-text-step-active: var(--prpl-color-headings, #38296d);
+ --prpl-color-border: #d1d5db; /* already exists in variables-color.css */
+
+ /* Button secondary */
+ --prpl-color-button-secondary: var(--prpl-background-point, #f9b23c );
+ --prpl-color-button-secondary-hover: var(--prpl-color-monthly, #faa310);
+ --prpl-color-button-secondary-text: #374151;
+ --prpl-color-button-secondary-icon: var(--prpl-color-button-secondary-text);
+
+ /* Button disabled */
+ --prpl-color-button-inactive: var(--prpl-color-gauge-remain, #e1e3e7 );
+ --prpl-color-button-inactive-text: var(--prpl-color-text, #4b5563);
+
+ --prpl-background-step-label: var(--prpl-background-step-active);
+ --prpl-color-step-label: var(--prpl-background-paper, #fff);
+
+ /* Required text */
+ --prpl-color-text-error: var(--prpl-color-button-primary, #dd324f);
+ --prpl-color-alert-error: #e73136; /* already exists in variables-color.css */
+
+ /* General error */
+ --prpl-background-alert-error: #fef2f2;/* already exists in variables-color.css */
+ --prpl-color-alert-error-text: #7f1d1d; /* already exists in variables-color.css */
+
+ /* Custom contros (inputs, radio, checkbox) */
+ --prpl-color-selection-controls-inactive: #9ca3af;
+ --prpl-color-selection-controls: var(--prpl-graph-color-4, #534786);
+
+ --prpl-color-field-border: var(--prpl-color-border);
+ --prpl-color-field-border-active: var(--prpl-color-alert-info, #2563eb);
+ --prpl-background-field-active: var(--prpl-background-alert-info, #eff6ff);
+
+
+ font-family: system-ui, Arial, sans-serif;
+
+ padding: 0;
+ box-sizing: border-box;
+
+ border-radius: 8px;
+ font-weight: 400;
+ max-height: 82vh;
+ width: 1200px;
+ max-width: 80vw;
+ color: var(--prpl-color-text);
+ background-color: var(--prpl-onboarding-popover-background);
+ border: 1px solid var(--prpl-color-ui-icon);
+
+ /* Popover backdrop. */
+ &::backdrop {
+ background: rgba(0, 0, 0, 0.5);
+ }
+
+ /* Popover close button. */
+ .prpl-popover-close {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ padding: 0.5em;
+ cursor: pointer;
+ background: none;
+ border: none;
+ color: var(--prpl-color-text);
+ z-index: 10;
+ }
+
+ /* General styles. */
+ & * {
+ box-sizing: border-box;
+ }
+
+ p {
+ font-size: 1rem;
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ h2 {
+ font-family: system-ui, Arial, sans-serif;
+ margin-top: 0;
+ margin-bottom: 0.75rem;
+ font-size: 1.25rem;
+ }
+
+ h3 {
+ font-family: system-ui, Arial, sans-serif;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--prpl-color-text);
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ /* Copied from WP Core CSS. */
+ select {
+ font-size: 14px;
+ line-height: 2;
+ color: #2c3338;
+ border-color: var(--prpl-color-field-border);
+ box-shadow: none;
+ border-radius: 3px;
+ padding: 0 24px 0 8px;
+ min-height: 30px;
+
+ /* max-width: 25rem; */
+ width: 100%;
+ -webkit-appearance: none;
+ background: #fff url(data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%206l5%205%205-5%202%201-7%207-7-7%202-1z%22%20fill%3D%22%23555%22%2F%3E%3C%2Fsvg%3E) no-repeat right 5px top 55%;
+ background-size: 16px 16px;
+ cursor: pointer;
+ vertical-align: middle;
+ }
+
+ input[type="text"],
+ input[type="email"] {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--prpl-color-field-border);
+ border-radius: 0.25rem;
+ font-size: 14px;
+ line-height: 1.4;
+ color: var(--prpl-color-text);
+ }
+
+ /* Used for radio and checkbox inputs. */
+ .prpl-custom-inputs-wrapper{
+ padding-left: 3px;
+
+ /* To prevent custom radio and checkbox from being cut off. */
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+
+ .prpl-checkbox-wrapper,
+ .prpl-radio-wrapper {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ }
+
+ /* Hide the default input, because WP has it's own styles (which include pseudo-elements). */
+ .prpl-custom-checkbox input[type="checkbox"],
+ .prpl-custom-radio input[type="radio"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ /* Shared styles for the custom radios and checkboxes. */
+ .prpl-custom-control {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 12px;
+ width: 20px;
+ height: 20px;
+ box-sizing: border-box;
+ position: relative;
+ transition: border-color 0.2s, background 0.2s;
+ flex-shrink: 0;
+ }
+
+ /* Label text styling */
+ .prpl-custom-checkbox,
+ .prpl-custom-radio {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ /* Checkbox styles */
+ .prpl-custom-checkbox {
+
+ .prpl-custom-control {
+ border: 1px solid var(--prpl-color-selection-controls-inactive);
+
+ /* border-radius: 6px; */
+ background-color: var(--prpl-onboarding-popover-background);
+ }
+
+ input[type="checkbox"] {
+
+ /* Checkbox checked (on) */
+ &:checked+.prpl-custom-control {
+ background: var(--prpl-color-selection-controls);
+ border-color: var(--prpl-color-selection-controls);
+ }
+ }
+
+ /* Checkmark */
+ .prpl-custom-control::after {
+ content: "";
+ position: absolute;
+ left: 6px;
+ top: 2px;
+ width: 4px;
+ height: 9px;
+ border: solid var(--prpl-onboarding-popover-background);
+ border-width: 0 2px 2px 0;
+ opacity: 0;
+ transform: scale(0.8) rotate(45deg);
+ transition: opacity 0.2s, transform 0.2s;
+ }
+
+ input[type="checkbox"]:checked+.prpl-custom-control::after {
+ opacity: 1;
+ transform: scale(1) rotate(45deg);
+ }
+ }
+
+ /* Radio styles */
+ .prpl-custom-radio {
+
+ .prpl-custom-control {
+ border: 1px solid var(--prpl-color-selection-controls-inactive);
+ border-radius: 50%;
+ background-color: var(--prpl-onboarding-popover-background);
+ }
+
+ /* Radio hover (off) */
+ input[type="radio"] {
+
+ /* Radio checked (on) */
+ &:checked+.prpl-custom-control {
+ background: var(--prpl-color-selection-controls);
+ border-color: var(--prpl-color-selection-controls);
+ }
+ }
+
+ /* Radio dot */
+ .prpl-custom-control::after {
+ content: "";
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ width: 8px;
+ height: 8px;
+ background-color: var(--prpl-onboarding-popover-background);
+ border-radius: 50%;
+ opacity: 0;
+ transition: opacity 0.2s;
+ }
+
+ input[type="radio"]:checked+.prpl-custom-control::after {
+ opacity: 1;
+ background-color: var(--prpl-onboarding-popover-background);
+ }
+ }
+ }
+
+ /* Main layout container */
+ .prpl-onboarding-layout {
+ display: flex;
+
+ /* gap: 1.5rem; */
+ min-height: 350px;
+ }
+
+ /* Left column: Step navigation */
+ .prpl-onboarding-navigation {
+ width: 340px;
+ background: var(--prpl-background-steps);
+ border-right: none;
+ padding: 2rem 1.5rem;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1.5rem;
+ }
+
+ .prpl-onboarding-logo {
+ height: 60px;
+ max-height: 100%;
+
+ img, svg {
+ height: 100%;
+ width: auto;
+ max-width: 100%;
+ }
+ }
+
+ .prpl-step-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .prpl-nav-step-item {
+ margin-top: 10px;
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 0;
+ margin-bottom: 4px;
+ cursor: default;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ .prpl-step-icon {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 1px solid var(--prpl-color-border);
+ color: var(--prpl-color-text);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-top: 2px;
+ font-size: 14px;
+ }
+
+ .prpl-nav-step-item.prpl-active .prpl-step-icon {
+ background: var(--prpl-background-step-active);
+ border-color: var(--prpl-background-step-active);
+ color: var(--prpl-color-number-step-active);
+ }
+
+ .prpl-nav-step-item.prpl-completed .prpl-step-icon {
+ background: var(--prpl-color-ui-icon);
+ border-color: var(--prpl-color-ui-icon);
+ color: var(--prpl-background-paper);
+ }
+
+ .prpl-step-label {
+ font-size: 15px;
+ color: var(--prpl-color-ui-icon);
+ line-height: 1.5;
+ }
+
+ .prpl-nav-step-item.prpl-active .prpl-step-label {
+ color: var(--prpl-color-text-step-active);
+ font-weight: 700;
+ }
+
+ .prpl-nav-step-item.prpl-completed .prpl-step-label {
+ color: var(--prpl-color-text-step-active);
+ }
+
+ #prpl-onboarding-mobile-step-label {
+ display: none;
+ margin-bottom: 0.25rem;
+ font-size: 14px;
+ color: var(--prpl-background-step-active);
+ font-weight: 600;
+ }
+
+ /* Right section: Content area */
+ .prpl-onboarding-content {
+ padding: 2rem 1.5rem;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+
+ /* gap: 1.5rem; */
+ }
+
+ .tour-content-wrapper {
+ flex: 1;
+
+ /* overflow-y: auto; */
+ }
+
+ .tour-header {
+
+ .tour-title {
+ color: var(--prpl-background-step-active);
+ font-weight: 600;
+ }
+ }
+
+ .tour-footer {
+ margin-top: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 10px;
+ width: 100%;
+
+ .prpl-tour-next-wrapper {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+ }
+
+ .tour-content {
+ font-size: 16px;
+ line-height: 1.5;
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ }
+
+ .prpl-columns-wrapper-flex {
+ display: flex;
+ gap: 1.5rem;
+
+ /* overflow: hidden; */
+
+ /* padding-bottom: 10px; */
+
+ .prpl-background-content {
+ padding: 20px;
+ border-radius: 12px;
+ background-color: var(--prpl-background-info);
+ }
+
+ .prpl-column {
+ flex-grow: 1;
+ flex-basis: 50%;
+ flex-direction: column;
+ }
+
+ &.prpl-columns-2-1 {
+
+ .prpl-column:first-child {
+ flex: 2 1 0;
+ min-width: 0;
+ }
+
+ .prpl-column:last-child {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+ }
+
+ &.prpl-columns-1-2 {
+
+ .prpl-column:first-child {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+
+ .prpl-column:last-child {
+ flex: 2 1 0;
+ min-width: 0;
+ }
+ }
+ }
+
+ .prpl-btn {
+ display: inline-block;
+ margin: 0;
+ padding: 0.75rem 1.25rem;
+
+ text-decoration: none;
+ cursor: pointer;
+ font-size: 16px;
+
+ /* color: var(--prpl-color-button-primary-text); */
+
+ /* background: var(--prpl-color-button-primary); */
+ background-color: var(--prpl-color-button-secondary);
+ color: var(--prpl-color-button-secondary-text);
+ line-height: 1.25;
+ box-shadow: none;
+ border: none;
+ border-radius: 6px;
+ transition: all 0.25s ease-in-out;
+ font-weight: 600;
+ text-align: center;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 1;
+
+ flex-shrink: 0;
+
+ /* &:not([disabled]):hover,
+ &:not([disabled]):focus {
+ background: var(--prpl-color-button-primary-hover);
+ } */
+
+ &.prpl-btn-secondary {
+ background-color: var(--prpl-color-button-secondary);
+ color: var(--prpl-color-button-secondary-text);
+
+ &:not([disabled]):not(.prpl-btn-disabled):hover,
+ &:not([disabled]):not(.prpl-btn-disabled):focus {
+ background-color: var(--prpl-color-button-secondary-hover);
+ color: var(--prpl-color-button-secondary-text);
+ }
+ }
+
+ &.prpl-btn-disabled,
+ &:disabled {
+
+ &,
+ &:hover{
+ color: var(--prpl-color-button-inactive-text);
+ color: rgb(from var(--prpl-color-button-inactive-text) r g b / 0.88);
+ background-color: var(--prpl-color-button-inactive);
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ .prpl-complete-task-btn:not(.prpl-btn) {
+ border: none;
+ background: none;
+ cursor: pointer;
+ padding: 0;
+ margin: 0;
+ font-size: 16px;
+ color: var(--prpl-color-link);
+ }
+
+/*
+ .prpl-complete-task-btn-completed:not(.prpl-btn) {
+ color: #059669;
+ pointer-events: none;
+ opacity: 0.5;
+ } */
+
+ .prpl-complete-task-btn-error {
+ color: #9f0712;
+ }
+
+ /* Generic error message (reusable for all steps) */
+ .prpl-error-message {
+ animation: prpl-slide-down 0.3s ease-out;
+ }
+
+ @keyframes prpl-slide-down {
+
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .prpl-error-box {
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+ background: var(--prpl-background-alert-error);
+ padding: 20px;
+ border-radius: 6px;
+ border-left: 4px solid var(--prpl-color-alert-error);
+
+ .prpl-error-icon {
+ color: var(--prpl-color-alert-error);
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+
+ img, svg {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ h3 {
+ margin: 0 0 8px 0;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--prpl-color-alert-error-text);
+ }
+
+ p {
+ margin: 0;
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--prpl-color-alert-error-text);
+ }
+ }
+
+ /* Popover: Quit confirmation */
+ &:has(.prpl-quit-confirmation) {
+
+ #prpl-tour-close-btn,
+ .prpl-onboarding-navigation {
+ display: none;
+ }
+ }
+
+ .prpl-column:has(.prpl-quit-confirmation) {
+ justify-content: flex-start !important;
+ }
+
+ .prpl-quit-confirmation {
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+ }
+
+ .prpl-quit-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 15px;
+ background: var(--prpl-background-alert-error);
+ padding: 20px;
+ border-radius: 6px;
+ border-left: 4px solid var(--prpl-color-alert-error);
+
+ h3 {
+ font-size: 18px;
+ font-weight: 700;
+ }
+
+ p {
+ font-size: 15px;
+ line-height: 1.6;
+ }
+ }
+
+ .prpl-quit-actions {
+ display: flex;
+ gap: 40px;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .prpl-quit-link {
+ font-size: 15px;
+ color: var(--prpl-color-link);
+ text-decoration: underline;
+ cursor: pointer;
+
+ &.prpl-quit-link-primary {
+ font-weight: 600;
+ font-size: 16px;
+ }
+ }
+
+ #prpl-quit-confirmation-graphic {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img,
+ svg {
+ height: 250px;
+
+ /* Set height since it is what we do in the PP dashboard. */
+ max-width: 100%;
+ }
+ }
+
+ /* Form for onboarding tasks. */
+ .prpl-onboarding-task-form {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: none;
+ padding: 0;
+ margin: 0;
+ background: none;
+
+ .prpl-complete-task-btn {
+ flex-shrink: 0;
+ margin-top: 1rem;
+ }
+ }
+
+ /* Welcome Step */
+ &[data-prpl-step="0"] {
+
+ .prpl-column:not(.prpl-column-content) {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ #prpl-welcome-graphic {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img, svg {
+ height: 250px; /* Set height since it is what we do in the PP dashboard. */
+ max-width: 100%;
+ }
+ }
+ }
+
+ /* Privacy checkbox */
+ .prpl-privacy-checkbox-wrapper {
+ margin-top: 20px;
+
+ label {
+ gap: 10px;
+ cursor: pointer;
+ font-size: 15px;
+ color: var(--prpl-color-text);
+ }
+
+ input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+ }
+
+ a {
+ color: var(--prpl-color-link);
+ text-decoration: underline;
+ }
+
+ .prpl-required-indicator {
+ font-size: 0.875rem;
+ font-style: italic;
+
+ &::before {
+ content: '(';
+ }
+
+ &::after {
+ content: ')';
+ }
+
+ &.prpl-required-indicator-active {
+ color: var(--prpl-color-text-error);
+ font-size: 1rem;
+ font-style: normal;
+
+ &::before {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ content: '!';
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background-color: var(--prpl-color-alert-error);
+ color: #fff;
+ font-size: 12px;
+ margin-right: 5px;
+ }
+
+ &::after {
+ content: '!';
+ }
+ }
+ }
+ }
+
+ /* What's Next Step */
+ .prpl-suggested-task-points {
+ font-size: var(--prpl-font-size-xs, 12px);
+ font-weight: 700;
+ color: var(--prpl-text-point);
+ background-color: var(--prpl-background-point);
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ /* First Task Step */
+ .prpl-onboarding-task {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+
+ /* .prpl-onboarding-task-title {
+ margin: 0 0 1rem 0;
+ font-size: 18px;
+ font-weight: 600;
+ } */
+
+ .prpl-onboarding-task-form {
+ margin-top: 1rem;
+ width: 100%;
+ flex-direction: column;
+
+ .prpl-complete-task-btn {
+ align-self: flex-end;
+
+ &.prpl-complete-task-btn-completed {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+ }
+ }
+
+ /* Badges Step */
+ .prpl-gauge-wrapper {
+ max-width: 100%;
+ text-align: center;
+ }
+
+ /* Email Frequency Step */
+
+ /* .prpl-email-frequency-options {
+
+ .
+ display: flex;
+ gap: 10px;
+ flex-direction: column;
+ } */
+
+ #prpl-email-form {
+ margin-top: 1rem;
+
+ .prpl-form-field {
+ margin-top: 1rem;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ label {
+ display: inline-block;
+ margin-bottom: 0.5rem;
+ }
+ }
+ }
+
+ /* Settings Step */
+ .tour-content-wrapper:has(.prpl-setting-item) {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ .tour-content,
+ .prpl-setting-item,
+ .prpl-setting-content {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: 1.5rem;
+ }
+
+ .prpl-setting-title {
+ margin: 0 0 1rem 0;
+ }
+
+ .prpl-settings-progress {
+ font-size: 14px;
+ color: var(--prpl-color-step-label);
+ padding: 4px 8px;
+ border-radius: 20px;
+ background-color: var(--prpl-background-step-active);
+ }
+
+ .prpl-setting-footer {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-start;
+ gap: 10px;
+ margin-top: 1rem;
+
+ .prpl-save-setting-btn {
+ flex-shrink: 0;
+ }
+ }
+
+ .prpl-select-page {
+
+ &.prpl-disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+
+ .prpl-setting-note {
+ display: none;
+ gap: 10px;
+ border-radius: 12px;
+ padding: 1rem;
+ color: var(--prpl-color-field-border-active);
+ font-size: 14px;
+ background-color: var(--prpl-background-field-active);
+
+ .prpl-setting-note-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ color: var(--prpl-color-field-border-active);
+ }
+ }
+
+ /* Post types sub-step */
+ #prpl-post-types-include-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ /* Toggle checkbox for post types. */
+ .prpl-post-type-toggle-wrapper {
+ display: flex;
+ align-items: center;
+ }
+
+ .prpl-post-type-toggle-label {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ cursor: pointer;
+ position: relative;
+ }
+
+ .prpl-post-type-toggle-input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ margin: 0;
+ padding: 0;
+ }
+
+ .prpl-post-type-toggle-switch {
+ position: relative;
+ width: 44px;
+ height: 24px;
+ border-radius: 12px;
+ background-color: var(--prpl-color-selection-controls-inactive);
+ transition: background-color 0.2s;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background-color: var(--prpl-onboarding-popover-background);
+ transition: transform 0.2s;
+ z-index: 1;
+ }
+
+ svg {
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ top: 50%;
+ left: 6px;
+ transform: translateY(-50%);
+ z-index: 2;
+ transition: opacity 0.2s, left 0.2s, color 0.2s;
+ color: var(--prpl-color-ui-icon);
+ }
+
+ .prpl-toggle-icon-check {
+ display: none;
+ }
+
+ .prpl-toggle-icon-x {
+ display: block;
+ }
+ }
+
+ .prpl-post-type-toggle-label:hover .prpl-post-type-toggle-switch svg {
+ opacity: 0.6;
+ }
+
+ .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch {
+ background-color: var(--prpl-background-step-active);
+
+ &::after {
+ transform: translateX(20px);
+ }
+
+ svg {
+ left: 26px;
+ color: var(--prpl-background-step-active);
+ transform: translateY(-50%);
+ }
+
+ .prpl-toggle-icon-check {
+ display: block;
+ }
+
+ .prpl-toggle-icon-x {
+ display: none;
+ }
+ }
+
+ .prpl-post-type-toggle-text {
+ font-size: 16px;
+ line-height: 1.5;
+ transition: opacity 0.2s;
+ }
+
+ .prpl-post-type-toggle-input:not(:checked) ~ .prpl-post-type-toggle-text {
+ opacity: 0.78;
+ }
+
+ .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-text {
+ opacity: 1;
+ }
+ }
+
+ /* More Tasks Step */
+
+ #prpl-success-graphic {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ img, svg {
+ height: 250px;
+ max-width: 100%;
+ }
+ }
+
+ /* Intro substep */
+ .prpl-more-tasks-substep[data-substep="more-tasks-intro"] {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ }
+
+ .prpl-more-tasks-intro-buttons {
+ margin-top: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ }
+
+ .prpl-finish-onboarding {
+ background: none;
+ border: none;
+ color: var(--prpl-color-link, #534786);
+ text-decoration: underline;
+ font-size: 16px;
+ cursor: pointer;
+ padding: 0.75rem 0;
+ }
+
+ .prpl-finish-onboarding:hover,
+ .prpl-finish-onboarding:focus {
+ color: var(--prpl-color-link-hover, #38296d);
+ }
+
+ /* Tasks substep */
+ .prpl-more-tasks-substep[data-substep="more-tasks-tasks"] .prpl-task-list {
+ margin: 0;
+ }
+
+ .prpl-column:has(.prpl-task-list) {
+ display: flex;
+ align-items: center;
+ }
+
+ .prpl-task-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ border: 1px solid #ccc;
+
+ li {
+ padding: 7px 5px;
+ margin: 0;
+
+ &:nth-child(odd) {
+ background-color: var(--prpl-background-steps);
+ }
+
+ .task-title {
+ color: var(--prpl-color-text);
+ font-weight: 500;
+ }
+ }
+ }
+
+ .prpl-complete-task-item {
+ display: flex;
+ gap: 30px;
+ justify-content: space-between;
+ }
+
+ .prpl-task-arrow {
+ padding-right: 0.5rem;
+ }
+
+ .prpl-task-item-button-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+ }
+
+ .prpl-task-completed-icon {
+ display: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ background-color: #059669;
+ }
+
+ .prpl-task-completed {
+
+ /* Show the completed icon. */
+ .prpl-task-completed-icon {
+ display: inline-flex;
+ }
+
+ /* Hide the trigger button and +1. */
+ .prpl-task-item-button-wrapper {
+ display: none;
+ }
+ }
+
+ .tour-content-wrapper:has(.prpl-task-content-active) {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ /* Task content active, TOOD: change markup so this is simpler. */
+ .prpl-task-content-active {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ gap: 1.5rem;
+ width: 100%;
+
+ .prpl-onboarding-task {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ .prpl-onboarding-task-form,
+ .tour-content {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ }
+
+ .prpl-onboarding-task-form {
+ justify-content: space-between;
+ gap: 3rem;
+ }
+ }
+ }
+
+ /* Task buttons. */
+ .prpl-task-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ width: 100%;
+
+ .prpl-btn {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ &.prpl-task-close-btn {
+ background-color: var(--prpl-background-banner);
+ color: var(--prpl-color-text);
+ }
+ }
+ }
+ }
+
+ /* File drop zone. */
+ .prpl-file-drop-zone {
+ width: 100%;
+ border-radius: 12px;
+ padding: 40px;
+ text-align: center;
+ color: var(--prpl-color-text);
+ transition: background 0.2s, border-color 0.2s;
+ cursor: pointer;
+ border: 2px dashed var(--prpl-color-field-border);
+
+ svg {
+
+ path {
+ stroke: var(--prpl-color-ui-icon);
+ }
+ }
+ }
+
+ .prpl-file-drop-zone.dragover {
+ background-color: var(--prpl-background-field-active);
+ border-color: var(--prpl-color-field-border-active);
+ }
+
+ /* When an image has been uploaded. */
+ .prpl-file-drop-zone.has-image {
+ border-color: var(--prpl-color-field-border-active);
+ background-color: var(--prpl-background-field-active);
+
+ & > .prpl-icon-image,
+ & > p,
+ & > .prpl-file-upload-hints {
+ display: none;
+ }
+
+ .prpl-file-preview img {
+ border: 2px solid var(--prpl-color-field-border);
+ border-radius: 8px;
+ padding: 8px;
+ background: var(--prpl-color-background-white);
+ }
+ }
+
+ .prpl-file-browse-link {
+ color: var(--prpl-color-link);
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ .prpl-file-remove-btn {
+ border: none;
+ background: none;
+ cursor: pointer;
+ padding: 0;
+ margin: 12px 0 0;
+ font-size: 16px;
+ color: var(--prpl-color-link);
+ text-decoration: underline;
+ }
+
+ .prpl-file-upload-hints {
+ display: flex;
+ flex-direction: column;
+ font-size: 14px;
+ color: var(--prpl-color-ui-icon);
+ }
+
+ /* WIP */
+ #prpl-upload-status {
+ margin-top: 10px;
+ font-family: monospace;
+ }
+
+ .prpl-file-preview {
+ display: none; /* Hidden by default. */
+ margin-top: 10px;
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 200px;
+ height: auto;
+ }
+}
+
+@media (max-width: 1023px) {
+
+ .prpl-popover-onboarding {
+ --prpl-mobile-nav-height: 60px;
+ max-width: 90vw;
+ inset: 0 0 var(--prpl-mobile-nav-height) 0; /* TODO: Adjust this for smallest screen sizes. */
+ margin: auto auto var(--prpl-mobile-nav-height) auto; /* Center in available space above nav */
+
+ /* Hide graphics */
+ .prpl-hide-on-mobile {
+ display: none !important;
+ }
+
+ /* Quit confirmation */
+ .prpl-quit-actions {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ /* Naviation section */
+ .prpl-onboarding-navigation {
+ position: fixed;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: var(--prpl-mobile-nav-height);
+ z-index: 1000;
+
+ flex-direction: row;
+ gap: 1rem;
+ justify-content: center;
+ align-items: center;
+ padding: 0.5rem;
+
+ .prpl-step-list {
+ display: flex;
+ gap: 0.5rem;
+
+ .prpl-nav-step-item {
+ margin: 0;
+ }
+
+ .prpl-step-label {
+ display: none;
+ }
+ }
+
+ #prpl-onboarding-mobile-step-label {
+ display: block;
+ }
+ }
+
+ /* Content section */
+ .prpl-columns-wrapper-flex {
+ flex-direction: column;
+ }
+
+ .prpl-setting-footer, /* On the settings steps */
+ .tour-footer {
+ flex-direction: column; /* So the info / error message is on top of the button. */
+ }
+
+ /* Badges step */
+ .prpl-gauge-wrapper {
+ max-width: 400px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ }
+}
diff --git a/assets/css/page-widgets/suggested-tasks.css b/assets/css/page-widgets/suggested-tasks.css
index 4f83b716c1..8fa3f87a79 100644
--- a/assets/css/page-widgets/suggested-tasks.css
+++ b/assets/css/page-widgets/suggested-tasks.css
@@ -33,14 +33,14 @@
background: none;
border: none;
padding: 0;
- color: var(--wp-admin-theme-color, #2271b1);
+ color: var(--prpl-color-link);
text-decoration: underline;
cursor: pointer;
font-size: inherit;
font-family: inherit;
&:hover {
- color: var(--wp-admin-theme-color-darker-10, #135e96);
+ color: var(--prpl-color-link-hover);
}
&:disabled {
@@ -495,3 +495,119 @@
}
}
}
+
+/*------------------------------------*\
+ Page select setting.
+\*------------------------------------*/
+.prpl-pages-item {
+
+ &:has(input[type="radio"][value="yes"]:checked),
+ &:has(input[type="radio"][value="no"]:checked) {
+
+ h3 {
+
+ .icon-exclamation-circle {
+ display: block;
+ }
+
+ .icon-check-circle {
+ display: none;
+ }
+ }
+ }
+
+ &:has(option[value=""]:not(:checked)):has(input[type="radio"][value="yes"]:checked),
+ &:has(input[type="radio"][value="not-applicable"]:checked) {
+
+ h3 {
+
+ .icon-check-circle {
+ display: block;
+ }
+
+ .icon-exclamation-circle {
+ display: none;
+ }
+ }
+ }
+
+ .item-actions,
+ .prpl-select-page {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .remind-button,
+ .assign-button {
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ h3 {
+ font-size: 1.15rem;
+ margin: 0;
+
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ .icon {
+ width: 1em;
+ height: 1em;
+ display: none;
+ }
+ }
+
+ p {
+ margin-block-start: 0.5rem;
+ margin-block-end: 1rem;
+ }
+
+ .radios {
+ margin-bottom: 1rem;
+ }
+
+ .prpl-radio-wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ [data-action="select"],
+ [data-action="create"] {
+ visibility: hidden;
+ }
+
+ &:has(input[type="radio"]:checked) {
+
+ [data-action="select"],
+ [data-action="create"] {
+ visibility: visible;
+ }
+ }
+
+ &:has(input[type="radio"][value="not-applicable"]) {
+ padding-top: 0.25rem;
+
+ /* Add bit height, because we dont have button or select */
+ }
+ }
+}
+
+/*------------------------------------*\
+ Post types selection.
+\*------------------------------------*/
+.prpl-post-types-selection {
+
+ label {
+ display: block;
+ margin-top: 0.75rem;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+}
diff --git a/assets/css/settings-page.css b/assets/css/settings-page.css
deleted file mode 100644
index 70d431ed64..0000000000
--- a/assets/css/settings-page.css
+++ /dev/null
@@ -1,350 +0,0 @@
-/* stylelint-disable-next-line selector-class-pattern */
-.progress-planner_page_progress-planner-settings {
-
- #wpwrap {
- background-color: var(--prpl-background);
- }
-
- ul#adminmenu {
-
- a.wp-has-current-submenu,
- > li.current > a.current {
-
- &::after {
- border-right-color: var(--prpl-background) !important;
- }
- }
- }
-
- .prpl-settings-wrap {
-
- h1 {
- display: flex;
- align-items: center;
- padding: 1.2rem;
- margin-bottom: 2rem;
-
- span {
- font-weight: 600;
- }
- }
-
- #prpl-settings {
-
- .prpl-widget-wrapper {
- padding: var(--prpl-settings-page-gap) var(--prpl-settings-page-gap) 2rem var(--prpl-settings-page-gap);
- }
- }
- }
-
- .prpl-settings-form-wrap {
- background-color: var(--prpl-background-paper);
-
- border: 1px solid var(--prpl-color-border);
- border-radius: var(--prpl-border-radius);
- padding: var(--prpl-padding);
- box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.07), -2px 0 6px rgba(0, 0, 0, 0.07);
- }
-
- .prpl-settings-section-wrapper {
- border: 1px solid var(--prpl-color-border);
- border-radius: var(--prpl-border-radius);
- padding: var(--prpl-padding);
- flex-grow: 1;
- }
-
- .prpl-settings-section-title {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- background: var(--prpl-background-monthly);
- padding: 1.2rem;
- border-radius: 0.5rem;
- margin-bottom: var(--prpl-padding);
-
- &:first-child {
- margin-top: 0;
- }
-
- .icon {
- width: 1.25em;
- height: 1.25em;
- }
- }
-
- .prpl-pages-item {
- border: 1px solid var(--prpl-color-border);
- border-radius: var(--prpl-border-radius);
- padding: var(--prpl-padding);
- flex-grow: 1;
- width: 45%;
-
- &:has(input[type="radio"][value="yes"]:checked),
- &:has(input[type="radio"][value="no"]:checked) {
-
- h3 {
-
- .icon-exclamation-circle {
- display: block;
- }
-
- .icon-check-circle {
- display: none;
- }
- }
- }
-
- &:has(option[value=""]:not(:checked)):has(input[type="radio"][value="yes"]:checked),
- &:has(input[type="radio"][value="not-applicable"]:checked) {
-
- h3 {
-
- .icon-check-circle {
- display: block;
- }
-
- .icon-exclamation-circle {
- display: none;
- }
- }
- }
-
- .item-actions,
- .prpl-select-page {
- display: flex;
- align-items: center;
- gap: 1rem;
- }
-
- .remind-button,
- .assign-button {
-
- svg {
- width: 1rem;
- height: 1rem;
- }
- }
-
- h3 {
- font-size: 1.15rem;
- margin: 0;
-
- display: flex;
- align-items: center;
- gap: 0.5rem;
-
- .icon {
- width: 1em;
- height: 1em;
- display: none;
- }
- }
-
- p {
- margin-block-start: 0.5rem;
- margin-block-end: 1rem;
- }
-
- .radios {
- margin-bottom: 1rem;
- }
-
- .prpl-radio-wrapper {
- display: flex;
- justify-content: space-between;
- align-items: center;
-
- [data-action="select"],
- [data-action="create"] {
- visibility: hidden;
- }
-
- &:has(input[type="radio"]:checked) {
-
- [data-action="select"],
- [data-action="create"] {
- visibility: visible;
- }
- }
-
- &:has(input[type="radio"][value="not-applicable"]) {
- padding-top: 0.25rem; /* Add bit height, because we dont have button or select */
- }
- }
- }
-
- .prpl-column-pages {
- margin-bottom: var(--prpl-gap);
-
- .prpl-settings-section-title {
- background: var(--prpl-background-setting-pages);
-
- .icon {
-
- path {
- fill: var(--prpl-color-setting-pages-icon);
- }
- }
- }
- }
-
- .prpl-pages-list {
- display: flex;
- flex-wrap: wrap;
- gap: var(--prpl-settings-page-gap);
-
- .item-description {
-
- h3 {
- margin-bottom: 2rem;
- }
-
- & > p {
- display: none;
- }
- }
-
- .radios {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- }
- }
-
- .prpl-button {
- color: var(--prpl-color-gray-7);
- text-decoration: none;
- border: 1px solid var(--prpl-color-border);
- border-radius: var(--prpl-border-radius);
- padding: 0.5em 0.5em;
- font-size: 14px; /* It matches font size, which comes from Core */
- display: inline-flex;
-
- &:hover,
- &:focus {
- color: var(--prpl-color-link);
- border-color: var(--prpl-color-link);
- background-color: var(--prpl-background-content-badge);
- }
- }
-
- .radios {
- display: flex;
- gap: 3rem;
- }
-}
-
-/* Post types */
-.prpl-column-post-types {
-
- .prpl-settings-section-title {
-
- svg {
- color: var(--prpl-color-setting-posts-icon);
-
- path {
- fill: currentcolor;
- }
- }
-
- background-color: var(--prpl-background-setting-posts);
- }
-
-}
-
-/* Login destination */
-.prpl-column-login-destination {
-
- .prpl-settings-section-title {
-
- svg {
- color: var(--prpl-color-setting-login-icon);
- }
-
- background-color: var(--prpl-background-setting-login);
- }
-
-}
-
-/* Grid layout for wrapper for:
-- Valuable post types
-- Default login destination
-- License keys
-*/
-#prpl-grid-column-wrapper {
- display: grid;
- margin-bottom: var(--prpl-gap);
-
- /* There are 5 or less valuable post types */
- grid-template-columns: 1fr 1fr;
- grid-template-rows: auto auto;
- gap: var(--prpl-settings-page-gap);
-
- .prpl-column {
- align-self: stretch;
- display: flex;
- flex-direction: column;
-
- .prpl-widget-wrapper {
- flex: 1;
- margin-bottom: 0;
- }
- }
-
- /* Valuable post types */
- .prpl-column:nth-child(1) {
- grid-column: 1;
- grid-row: 1;
- }
-
- /* Default login destination */
- .prpl-column:nth-child(2) {
- grid-column: 2;
- grid-row: 1;
- }
-
- /* License keys */
- .prpl-column:nth-child(3) {
- grid-column: 1 / span 2;
- grid-row: 2;
- }
-
- /* We have more than 5 valuable post types */
- &:has([data-has-many-valuable-post-types]) {
- grid-template-rows: auto auto;
-
- /* Valuable post types */
- .prpl-column:nth-child(1) {
- grid-column: 1;
- grid-row: 1 / span 2;
-
- /* Span 2 rows on the left */
- }
-
- /* Default login destination */
- .prpl-column:nth-child(2) {
- grid-column: 2;
- grid-row: 1;
- }
-
- /* License keys */
- .prpl-column:nth-child(3) {
- grid-column: 2;
- grid-row: 2;
- }
- }
-}
-
-/* Valuable post types */
-#prpl-post-types-include-wrapper {
- padding-top: 0.75rem;
-
- label {
- display: block;
- margin-top: 0.75rem;
-
- &:first-child {
- margin-top: 0;
- }
- }
-}
diff --git a/assets/css/welcome.css b/assets/css/welcome.css
deleted file mode 100644
index 20004600b4..0000000000
--- a/assets/css/welcome.css
+++ /dev/null
@@ -1,77 +0,0 @@
-.prpl-wrap.prpl-pp-not-accepted {
- padding: 0;
- background-color: var(--prpl-background-paper);
- border: 1px solid var(--prpl-color-border);
- border-radius: var(--prpl-border-radius);
-}
-
-.prpl-welcome {
-
- .inner-content {
- padding: calc(var(--prpl-gap) * 1.5);
- padding-bottom: 0;
- margin-bottom: calc(var(--prpl-gap) * 1.5);
- display: flex;
- grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
- gap: calc(var(--prpl-gap) * 2);
-
- .left {
- flex-grow: 1;
- }
-
- img {
- max-width: 100%;
- width: 550px;
- height: auto;
- }
- }
-
- .welcome-header {
- background: var(--prpl-background-banner);
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-top-left-radius: var(--prpl-border-radius);
- border-top-right-radius: var(--prpl-border-radius);
- overflow: hidden;
-
- h1 {
- font-size: var(--prpl-font-size-3xl);
- padding: var(--prpl-padding) calc(var(--prpl-gap) * 1.5);
- font-weight: 600;
- }
-
- .welcome-header-icon {
- background: var(--prpl-background-banner);
- background: linear-gradient(105deg, var(--prpl-background-banner) 25%, var(--prpl-background-monthly) 25%);
- padding: var(--prpl-padding);
- padding-left: 100px;
- padding-right: calc(var(--prpl-gap) * 1.5);
-
- svg {
- height: 100px;
- }
- }
- }
-
- .prpl-form-notice-title {
- font-size: var(--prpl-font-size-lg);
- }
-
- ul {
- list-style: disc;
- margin-left: 1rem;
- }
-
- .prpl-onboard-form-radio-select {
- margin-top: 0.75rem;
-
- label {
- margin-top: 0.5rem;
-
- &:first-child {
- margin-top: 0;
- }
- }
- }
-}
diff --git a/assets/images/onboarding/icon_image.svg b/assets/images/onboarding/icon_image.svg
new file mode 100644
index 0000000000..24f79c262a
--- /dev/null
+++ b/assets/images/onboarding/icon_image.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/images/onboarding/icon_info_solid.svg b/assets/images/onboarding/icon_info_solid.svg
new file mode 100644
index 0000000000..9b6e2d98f2
--- /dev/null
+++ b/assets/images/onboarding/icon_info_solid.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/assets/images/onboarding/neglected_site_ravi.svg b/assets/images/onboarding/neglected_site_ravi.svg
new file mode 100644
index 0000000000..14acee56f5
--- /dev/null
+++ b/assets/images/onboarding/neglected_site_ravi.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/onboarding/success_ravi.svg b/assets/images/onboarding/success_ravi.svg
new file mode 100644
index 0000000000..45e8535d4e
--- /dev/null
+++ b/assets/images/onboarding/success_ravi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/onboarding/thumbs_up_ravi_rtl.svg b/assets/images/onboarding/thumbs_up_ravi_rtl.svg
new file mode 100644
index 0000000000..27234bc510
--- /dev/null
+++ b/assets/images/onboarding/thumbs_up_ravi_rtl.svg
@@ -0,0 +1 @@
+
diff --git a/assets/js/color-customizer.js b/assets/js/color-customizer.js
deleted file mode 100644
index f294b0d0a5..0000000000
--- a/assets/js/color-customizer.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Color Customizer JavaScript
- *
- * @package
- */
-
-( function () {
- 'use strict';
-
- // Normalize color value to 6-digit hex format
- function normalizeColorValue( colorValue ) {
- if ( ! colorValue ) {
- return null;
- }
-
- // Handle special cases
- if ( colorValue === 'none' ) {
- return '#000000';
- }
-
- // If it's already a 6-digit hex, return as is
- if ( colorValue.match( /^#[0-9A-Fa-f]{6}$/ ) ) {
- return colorValue.toUpperCase();
- }
-
- // Convert 3-digit hex to 6-digit
- if ( colorValue.match( /^#[0-9A-Fa-f]{3}$/ ) ) {
- const hex = colorValue.substring( 1 );
- return (
- '#' +
- hex[ 0 ] +
- hex[ 0 ] +
- hex[ 1 ] +
- hex[ 1 ] +
- hex[ 2 ] +
- hex[ 2 ]
- );
- }
-
- // If it's not a valid hex color, return null
- return null;
- }
-
- document.addEventListener( 'DOMContentLoaded', function () {
- // Sync color picker with text input
- const colorPickers = document.querySelectorAll( '.color-picker' );
- const textInputs = document.querySelectorAll( '.color-text-input' );
-
- colorPickers.forEach( function ( picker, index ) {
- const textInput = textInputs[ index ];
-
- if ( ! textInput ) {
- return;
- }
-
- // Update text input when color picker changes
- picker.addEventListener( 'input', function () {
- textInput.value = this.value;
- } );
-
- // Update color picker when text input changes
- textInput.addEventListener( 'input', function () {
- const normalizedValue = normalizeColorValue( this.value );
- if ( normalizedValue ) {
- picker.value = normalizedValue;
- this.value = normalizedValue;
- }
- } );
-
- // Validate color format on blur
- textInput.addEventListener( 'blur', function () {
- const normalizedValue = normalizeColorValue( this.value );
- if ( this.value && ! normalizedValue ) {
- this.style.borderColor = '#e73136';
- this.title =
- 'Please enter a valid hex color (e.g., #ff0000 or #fff)';
- } else {
- this.style.borderColor = '';
- this.title = '';
- if ( normalizedValue && normalizedValue !== this.value ) {
- this.value = normalizedValue;
- picker.value = normalizedValue;
- }
- }
- } );
- } );
- } );
-} )();
diff --git a/assets/js/license-generator.js b/assets/js/license-generator.js
new file mode 100644
index 0000000000..5dfc2bb109
--- /dev/null
+++ b/assets/js/license-generator.js
@@ -0,0 +1,140 @@
+/**
+ * License Generator - Handles license key generation during onboarding
+ * Adapted from onboard.js
+ */
+/* global progressPlanner */
+
+// eslint-disable-next-line no-unused-vars
+class LicenseGenerator {
+ /**
+ * Store config for use in other methods.
+ *
+ * @type {Object}
+ */
+ static config = null;
+
+ /**
+ * Get the default config from progressPlanner global.
+ *
+ * @return {Object} Default configuration object.
+ */
+ static getDefaultConfig() {
+ // eslint-disable-next-line no-undef
+ if ( typeof progressPlanner !== 'undefined' ) {
+ return {
+ // eslint-disable-next-line no-undef
+ onboardNonceURL: progressPlanner.onboardNonceURL,
+ // eslint-disable-next-line no-undef
+ onboardAPIUrl: progressPlanner.onboardAPIUrl,
+ // eslint-disable-next-line no-undef
+ adminAjaxUrl: progressPlanner.ajaxUrl,
+ // eslint-disable-next-line no-undef
+ nonce: progressPlanner.nonce,
+ };
+ }
+ return {};
+ }
+
+ /**
+ * Make a request to save the license key.
+ *
+ * @param {string} licenseKey The license key.
+ * @return {Promise} Promise that resolves when license is saved
+ */
+ static saveLicenseKey( licenseKey ) {
+ console.log( 'License key: ' + licenseKey );
+ return LicenseGenerator.ajaxRequest( {
+ url: LicenseGenerator.config.adminAjaxUrl,
+ data: {
+ action: 'progress_planner_save_onboard_data',
+ _ajax_nonce: LicenseGenerator.config.nonce,
+ key: licenseKey,
+ },
+ } );
+ }
+
+ /**
+ * Make the AJAX request to the API.
+ *
+ * @param {Object} data The data to send with the request.
+ * @return {Promise} Promise that resolves when request completes
+ */
+ static ajaxAPIRequest( data ) {
+ return LicenseGenerator.ajaxRequest( {
+ url: LicenseGenerator.config.onboardAPIUrl,
+ data,
+ } )
+ .then( ( response ) => {
+ // Make a local request to save the response data.
+ return LicenseGenerator.saveLicenseKey( response.license_key );
+ } )
+ .catch( ( error ) => {
+ console.warn( error );
+ throw error;
+ } );
+ }
+
+ /**
+ * Make the AJAX request.
+ *
+ * Make a request to get the nonce.
+ * Once the nonce is received, make a request to the API.
+ *
+ * @param {Object} data The data to send with the request.
+ * @param {Object} config Optional configuration object. Falls back to progressPlanner global.
+ * @return {Promise} Promise that resolves when license is generated
+ */
+ static generateLicense( data = {}, config = null ) {
+ // Store config for use in other methods, fall back to default if not provided.
+ LicenseGenerator.config = config || LicenseGenerator.getDefaultConfig();
+
+ return LicenseGenerator.ajaxRequest( {
+ url: LicenseGenerator.config.onboardNonceURL,
+ data,
+ } ).then( ( response ) => {
+ if ( 'ok' === response.status ) {
+ // Add the nonce to our data object.
+ data.nonce = response.nonce;
+
+ // Make the request to the API.
+ return LicenseGenerator.ajaxAPIRequest( data );
+ }
+ // Handle error response
+ const errorMessage =
+ response.message ||
+ 'Failed to get nonce for license generation';
+ throw new Error( errorMessage );
+ } );
+ }
+
+ /**
+ * Helper function to make AJAX requests
+ *
+ * @param {Object} options Request options
+ * @param {string} options.url The URL to send the request to
+ * @param {Object} options.data The data to send with the request
+ * @return {Promise} Promise that resolves with response data
+ */
+ static ajaxRequest( options ) {
+ const { url, data } = options;
+
+ return fetch( url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams( data ),
+ credentials: 'same-origin',
+ } )
+ .then( ( response ) => {
+ if ( ! response.ok ) {
+ throw new Error( 'Request failed: ' + response.status );
+ }
+ return response.json();
+ } )
+ .catch( ( error ) => {
+ console.error( 'AJAX request error:', error );
+ throw error;
+ } );
+ }
+}
diff --git a/assets/js/onboard.js b/assets/js/onboard.js
deleted file mode 100644
index 529b05831b..0000000000
--- a/assets/js/onboard.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/* global progressPlanner, progressPlannerAjaxRequest */
-/*
- * Onboard
- *
- * A script to handle the onboarding process.
- *
- * Dependencies: progress-planner/ajax-request, progress-planner/upgrade-tasks
- */
-
-/**
- * Make a request to save the license key.
- *
- * @param {string} licenseKey The license key.
- */
-const progressPlannerSaveLicenseKey = ( licenseKey ) => {
- console.log( 'License key: ' + licenseKey );
- return progressPlannerAjaxRequest( {
- url: progressPlanner.ajaxUrl,
- data: {
- action: 'progress_planner_save_onboard_data',
- _ajax_nonce: progressPlanner.nonce,
- key: licenseKey,
- },
- } );
-};
-
-/**
- * Make the AJAX request.
- *
- * @param {Object} data The data to send with the request.
- */
-const progressPlannerAjaxAPIRequest = ( data ) => {
- progressPlannerAjaxRequest( {
- url: progressPlanner.onboardAPIUrl,
- data,
- } )
- .then( ( response ) => {
- // Make a local request to save the response data.
- progressPlannerSaveLicenseKey( response.license_key ).then( () => {
- // Refresh the page.
- window.location.reload();
- } );
- } )
- .catch( ( error ) => {
- console.warn( error );
- } );
-};
-
-/**
- * Make the AJAX request.
- *
- * Make a request to get the nonce.
- * Once the nonce is received, make a request to the API.
- *
- * @param {Object} data The data to send with the request.
- */
-const progressPlannerOnboardCall = ( data ) => {
- progressPlannerAjaxRequest( {
- url: progressPlanner.onboardNonceURL,
- data,
- } ).then( ( response ) => {
- if ( 'ok' === response.status ) {
- // Add the nonce to our data object.
- data.nonce = response.nonce;
-
- // Make the request to the API.
- progressPlannerAjaxAPIRequest( data );
- }
- } );
-};
-
-if ( document.getElementById( 'prpl-onboarding-form' ) ) {
- document
- .querySelectorAll( 'input[name="with-email"]' )
- .forEach( ( input ) => {
- input.addEventListener( 'change', function () {
- if ( 'no' === this.value ) {
- document
- .getElementById( 'prpl-onboarding-form' )
- .querySelectorAll( 'input' )
- .forEach( ( inputField ) => {
- inputField.required = false;
- } );
- } else {
- document
- .getElementById( 'prpl-onboarding-form' )
- .querySelectorAll( 'input' )
- .forEach( ( inputField ) => {
- if (
- 'name' === inputField.name ||
- 'email' === inputField.name
- ) {
- inputField.required = true;
- }
- } );
- }
- document
- .getElementById( 'prpl-onboarding-form' )
- .querySelectorAll(
- '.prpl-form-fields, .prpl-form-fields, .prpl-button-primary, .prpl-button-secondary--no-email'
- )
- .forEach( ( el ) => el.classList.toggle( 'prpl-hidden' ) );
- } );
- } );
-
- document
- .querySelector( '#prpl-onboarding-form input[name="privacy-policy"]' )
- .addEventListener( 'change', function () {
- const privacyPolicyAccepted = !! this.checked;
-
- if ( privacyPolicyAccepted ) {
- document
- .getElementById( 'prpl-onboarding-submit-wrapper' )
- .classList.remove( 'prpl-disabled' );
- } else {
- document
- .getElementById( 'prpl-onboarding-submit-wrapper' )
- .classList.add( 'prpl-disabled' );
- }
- } );
-
- document
- .getElementById( 'prpl-onboarding-form' )
- .addEventListener( 'submit', function ( event ) {
- event.preventDefault();
-
- const privacyPolicyAccepted = !! document.querySelector(
- '#prpl-onboarding-form input[name="privacy-policy"]'
- ).checked;
-
- // Make sure the user accepted the privacy policy.
- if ( ! privacyPolicyAccepted ) {
- return;
- }
-
- // Disable all (both buttons) submit buttons.
- document
- .querySelectorAll(
- '#prpl-onboarding-form input[type="submit"]'
- )
- .forEach( ( input ) => {
- input.disabled = true;
- } );
-
- // Show the spinner.
- const spinner = document.createElement( 'span' );
- spinner.classList.add( 'prpl-spinner' );
- spinner.innerHTML =
- ' '; // WP spinner.
-
- // Append spinner after submit button.
-
- document
- .getElementById( 'prpl-onboarding-submit-wrapper' )
- .appendChild( spinner );
-
- // Get all form data.
- const data = Object.fromEntries( new FormData( event.target ) );
-
- // If the user doesn't want to use email, remove the email and name.
- if ( 'no' === data.with_email ) {
- data.email = '';
- data.name = '';
- }
-
- progressPlannerOnboardCall( data );
- } );
-}
diff --git a/assets/js/onboarding/OnboardTask.js b/assets/js/onboarding/OnboardTask.js
new file mode 100644
index 0000000000..2db23b7816
--- /dev/null
+++ b/assets/js/onboarding/OnboardTask.js
@@ -0,0 +1,470 @@
+/**
+ * OnboardTask - Handles individual tasks that open within the column
+ * Used by the MoreTasksStep for tasks that require user input
+ * Toggles visibility of task list and shows task content in the same column
+ */
+/* global ProgressPlannerOnboardData, ProgressPlannerTourUtils */
+
+// eslint-disable-next-line no-unused-vars
+class PrplOnboardTask {
+ constructor( el, wizard ) {
+ this.el = el;
+ this.id = el.dataset.taskId;
+ this.wizard = wizard;
+ this.taskContent = null;
+ this.formValues = {};
+ this.openTaskBtn = el.querySelector( '[prpl-open-task]' );
+
+ // Register task open event
+ this.openTaskBtn?.addEventListener( 'click', () => this.open() );
+ }
+
+ /**
+ * Get the tour footer element via the current step
+ * @return {HTMLElement|null} The tour footer element or null if not found
+ */
+ getTourFooter() {
+ // Get the current step and use its getTourFooter method
+ const currentStep =
+ this.wizard?.tourSteps?.[ this.wizard.state.currentStep ];
+ if ( currentStep && typeof currentStep.getTourFooter === 'function' ) {
+ return currentStep.getTourFooter();
+ }
+
+ // Fallback in case step doesn't have the method
+ return this.wizard?.contentWrapper?.querySelector( '.tour-footer' );
+ }
+
+ registerEvents() {
+ this.taskContent.addEventListener( 'click', ( e ) => {
+ if ( e.target.classList.contains( 'prpl-complete-task-btn' ) ) {
+ const formData = new FormData(
+ this.taskContent.querySelector( 'form' )
+ );
+ this.formValues = Object.fromEntries( formData.entries() );
+ this.complete();
+ }
+ } );
+
+ // Close button handler
+ const closeBtn = this.taskContent.querySelector(
+ '.prpl-task-close-btn'
+ );
+ closeBtn?.addEventListener( 'click', () => this.close() );
+
+ this.setupFormValidation();
+
+ // Initialize upload handling (only if upload field exists)
+ this.setupFileUpload();
+
+ this.el.addEventListener( 'prplFileUploaded', ( e ) => {
+ // Handle file upload for the 'set site icon' task.
+ if ( 'core-siteicon' === e.detail.fileInput.dataset.taskId ) {
+ // Element which will be used to store the file post ID.
+ const nextElementSibling =
+ e.detail.fileInput.nextElementSibling;
+
+ nextElementSibling.value = e.detail.filePost.id;
+
+ // Trigger change so validation is triggered and "Complete" button is enabled.
+ nextElementSibling.dispatchEvent(
+ new CustomEvent( 'change', {
+ bubbles: true,
+ } )
+ );
+ }
+ } );
+ }
+
+ open() {
+ if ( this.taskContent ) {
+ return; // Already open
+ }
+
+ // Find the column containing the task list
+ const taskList = this.wizard.popover.querySelector( '.prpl-task-list' );
+ if ( ! taskList ) {
+ return;
+ }
+
+ const column = taskList.closest( '.prpl-column' );
+ if ( ! column ) {
+ return;
+ }
+
+ // Hide the task list
+ taskList.style.display = 'none';
+
+ // Hide the tour footer (it's part of the step content)
+ const tourFooter = this.getTourFooter();
+ if ( tourFooter ) {
+ tourFooter.style.display = 'none';
+ }
+
+ // Get task content from template
+ const content = this.el
+ .querySelector( 'template' )
+ .content.cloneNode( true );
+
+ // Create task content wrapper
+ this.taskContent = document.createElement( 'div' );
+ this.taskContent.className = 'prpl-task-content-active';
+ this.taskContent.appendChild( content );
+
+ // Find the complete button in the form
+ const completeBtn = this.taskContent.querySelector(
+ '.prpl-complete-task-btn'
+ );
+
+ if ( completeBtn ) {
+ // Create close button
+ const closeBtn = document.createElement( 'button' );
+ closeBtn.type = 'button';
+ closeBtn.className = 'prpl-btn prpl-task-close-btn';
+ closeBtn.innerHTML =
+ ' ' +
+ ProgressPlannerOnboardData.l10n.backToRecommendations;
+
+ // Create button wrapper
+ const buttonWrapper = document.createElement( 'div' );
+ buttonWrapper.className = 'prpl-task-buttons';
+
+ // Move complete button into wrapper
+ completeBtn.parentNode.insertBefore( buttonWrapper, completeBtn );
+ buttonWrapper.appendChild( closeBtn );
+ buttonWrapper.appendChild( completeBtn );
+ }
+
+ // Add task content to the column
+ column.appendChild( this.taskContent );
+
+ // Hide the popover close button
+ const popoverCloseBtn = this.wizard.popover.querySelector(
+ '#prpl-tour-close-btn'
+ );
+ if ( popoverCloseBtn ) {
+ popoverCloseBtn.style.display = 'none';
+ }
+
+ // Register events
+ this.registerEvents();
+ }
+
+ close() {
+ if ( ! this.taskContent ) {
+ return;
+ }
+
+ // Remove task content
+ this.taskContent.remove();
+
+ // Show the task list
+ const taskList = this.wizard.popover.querySelector( '.prpl-task-list' );
+ if ( taskList ) {
+ taskList.style.display = '';
+ }
+
+ // Show the tour footer (it's part of the step content)
+ const tourFooter = this.getTourFooter();
+ if ( tourFooter ) {
+ tourFooter.style.display = '';
+ }
+
+ // Show the popover close button
+ const popoverCloseBtn = this.wizard.popover.querySelector(
+ '#prpl-tour-close-btn'
+ );
+ if ( popoverCloseBtn ) {
+ popoverCloseBtn.style.display = '';
+ }
+
+ // Clean up
+ this.taskContent = null;
+ }
+
+ complete() {
+ ProgressPlannerTourUtils.completeTask( this.id, this.formValues )
+ .then( () => {
+ this.el.classList.add( 'prpl-task-completed' );
+ const taskBtn = this.el.querySelector(
+ '.prpl-complete-task-btn'
+ );
+ if ( taskBtn ) {
+ taskBtn.disabled = true;
+ }
+
+ this.close();
+ this.notifyParent();
+ } )
+ .catch( ( error ) => {
+ console.error( error );
+ // TODO: Handle error.
+ } );
+ }
+
+ notifyParent() {
+ const event = new CustomEvent( 'taskCompleted', {
+ bubbles: true,
+ detail: { id: this.id, formValues: this.formValues },
+ } );
+ this.el.dispatchEvent( event );
+ }
+
+ setupFormValidation() {
+ const form = this.taskContent.querySelector( 'form' );
+ const submitButton = this.taskContent.querySelector(
+ '.prpl-complete-task-btn'
+ );
+
+ if ( ! form || ! submitButton ) {
+ return;
+ }
+
+ const validateElements = form.querySelectorAll( '[data-validate]' );
+ if ( validateElements.length === 0 ) {
+ return;
+ }
+
+ const checkValidation = () => {
+ let isValid = true;
+
+ validateElements.forEach( ( element ) => {
+ const validationType = element.getAttribute( 'data-validate' );
+ let elementValid = false;
+
+ switch ( validationType ) {
+ case 'required':
+ elementValid =
+ element.value !== null &&
+ element.value !== undefined &&
+ element.value !== '';
+ break;
+ case 'not-empty':
+ elementValid = element.value.trim() !== '';
+ break;
+ default:
+ elementValid = true;
+ }
+
+ if ( ! elementValid ) {
+ isValid = false;
+ }
+ } );
+
+ submitButton.disabled = ! isValid;
+ };
+
+ checkValidation();
+ validateElements.forEach( ( element ) => {
+ element.addEventListener( 'change', checkValidation );
+ element.addEventListener( 'input', checkValidation );
+ } );
+ }
+
+ /**
+ * Handles drag-and-drop or manual file upload for specific tasks.
+ * Only runs if the form contains an upload field.
+ */
+ setupFileUpload() {
+ const uploadContainer = this.taskContent.querySelector(
+ '[data-upload-field]'
+ );
+ if ( ! uploadContainer ) {
+ return;
+ } // no upload for this task
+
+ const fileInput = uploadContainer.querySelector( 'input[type="file"]' );
+ const statusDiv = uploadContainer.querySelector(
+ '.prpl-upload-status'
+ );
+
+ // Visual drag behavior
+ [ 'dragenter', 'dragover' ].forEach( ( event ) => {
+ uploadContainer.addEventListener( event, ( e ) => {
+ e.preventDefault();
+ uploadContainer.classList.add( 'dragover' );
+ } );
+ } );
+
+ [ 'dragleave', 'drop' ].forEach( ( event ) => {
+ uploadContainer.addEventListener( event, ( e ) => {
+ e.preventDefault();
+ uploadContainer.classList.remove( 'dragover' );
+ } );
+ } );
+
+ uploadContainer.addEventListener( 'drop', ( e ) => {
+ const file = e.dataTransfer.files[ 0 ];
+ if ( file ) {
+ this.uploadFile( file, statusDiv ).then( ( response ) => {
+ this.el.dispatchEvent(
+ new CustomEvent( 'prplFileUploaded', {
+ detail: { file, filePost: response, fileInput },
+ bubbles: true,
+ } )
+ );
+ } );
+ }
+ } );
+
+ fileInput?.addEventListener( 'change', ( e ) => {
+ const file = e.target.files[ 0 ];
+ if ( file ) {
+ this.uploadFile( file, statusDiv, fileInput ).then(
+ ( response ) => {
+ this.el.dispatchEvent(
+ new CustomEvent( 'prplFileUploaded', {
+ detail: { file, filePost: response, fileInput },
+ bubbles: true,
+ } )
+ );
+ }
+ );
+ }
+ } );
+
+ // Remove button handler.
+ const removeBtn = uploadContainer.querySelector( '.prpl-file-remove-btn' );
+ const previewDiv = uploadContainer.querySelector( '.prpl-file-preview' );
+ removeBtn?.addEventListener( 'click', () => {
+ this.removeUploadedFile( uploadContainer, previewDiv );
+ } );
+ }
+
+ async uploadFile( file, statusDiv ) {
+ // Validate file extension
+ if ( ! this.isValidFaviconFile( file ) ) {
+ const fileInput =
+ this.taskContent.querySelector( 'input[type="file"]' );
+ const acceptedTypes = fileInput?.accept || 'supported file types';
+ statusDiv.textContent = `Invalid file type. Please upload a file with one of these formats: ${ acceptedTypes }`;
+ return;
+ }
+
+ statusDiv.textContent = `Uploading ${ file.name }...`;
+
+ const formData = new FormData();
+ formData.append( 'file', file );
+ formData.append( 'prplFileUpload', '1' );
+
+ return fetch( '/wp-json/wp/v2/media', {
+ method: 'POST',
+ headers: {
+ 'X-WP-Nonce': ProgressPlannerOnboardData.nonceWPAPI, // usually wp_localize_script adds this
+ },
+ body: formData,
+ credentials: 'same-origin',
+ } )
+ .then( ( res ) => {
+ if ( 201 !== res.status ) {
+ throw new Error( 'Failed to upload file' );
+ }
+ return res.json();
+ } )
+ .then( ( response ) => {
+ // Testing only, no need to display file name in production.
+ // statusDiv.textContent = `${ file.name } uploaded.`;
+ statusDiv.style.display = 'none';
+
+ // Update the file preview.
+ const previewDiv =
+ this.taskContent.querySelector( '.prpl-file-preview' );
+ if ( previewDiv ) {
+ previewDiv.innerHTML = ` `;
+ previewDiv.style.display = 'block';
+
+ // Add has-image class to drop zone to update styling.
+ const dropZone = this.taskContent.querySelector(
+ '.prpl-file-drop-zone'
+ );
+ if ( dropZone ) {
+ dropZone.classList.add( 'has-image' );
+
+ // Show the remove button.
+ const removeBtn = dropZone.querySelector(
+ '.prpl-file-remove-btn'
+ );
+ if ( removeBtn ) {
+ removeBtn.hidden = false;
+ }
+ }
+ }
+ return response;
+ } )
+ .catch( ( error ) => {
+ console.error( error );
+ statusDiv.textContent = `Error: ${ error.message }`;
+ } );
+ }
+
+ /**
+ * Validate if file matches the accepted file types from the input
+ * @param {File} file The file to validate
+ * @return {boolean} True if file extension is supported
+ */
+ isValidFaviconFile( file ) {
+ const fileInput =
+ this.taskContent.querySelector( 'input[type="file"]' );
+ if ( ! fileInput || ! fileInput.accept ) {
+ return true; // No restrictions if no accept attribute
+ }
+
+ const acceptedTypes = fileInput.accept
+ .split( ',' )
+ .map( ( type ) => type.trim() );
+ const fileName = file.name.toLowerCase();
+
+ return acceptedTypes.some( ( type ) => {
+ if ( type.startsWith( '.' ) ) {
+ // Extension-based validation
+ return fileName.endsWith( type );
+ } else if ( type.includes( '/' ) ) {
+ // MIME type-based validation
+ return file.type === type;
+ }
+ return false;
+ } );
+ }
+
+ /**
+ * Remove uploaded file and reset the drop zone state.
+ * @param {HTMLElement} dropZone The drop zone element.
+ * @param {HTMLElement} previewDiv The preview container element.
+ */
+ removeUploadedFile( dropZone, previewDiv ) {
+ // Clear the preview.
+ previewDiv.innerHTML = '';
+ previewDiv.style.display = 'none';
+
+ // Remove has-image class.
+ dropZone.classList.remove( 'has-image' );
+
+ // Hide the remove button.
+ const removeBtn = dropZone.querySelector( '.prpl-file-remove-btn' );
+ if ( removeBtn ) {
+ removeBtn.hidden = true;
+ }
+
+ // Clear the file input.
+ const fileInput = dropZone.querySelector( 'input[type="file"]' );
+ if ( fileInput ) {
+ fileInput.value = '';
+ }
+
+ // Clear the hidden post_id input and trigger validation.
+ const postIdInput = dropZone.querySelector( 'input[name="post_id"]' );
+ if ( postIdInput ) {
+ postIdInput.value = '';
+ postIdInput.dispatchEvent(
+ new CustomEvent( 'change', { bubbles: true } )
+ );
+ }
+
+ // Show status div again.
+ const statusDiv = dropZone.querySelector( '.prpl-upload-status' );
+ if ( statusDiv ) {
+ statusDiv.style.display = '';
+ statusDiv.textContent = '';
+ }
+ }
+}
diff --git a/assets/js/onboarding/onboarding.js b/assets/js/onboarding/onboarding.js
new file mode 100644
index 0000000000..3bda0afeb6
--- /dev/null
+++ b/assets/js/onboarding/onboarding.js
@@ -0,0 +1,501 @@
+/**
+ * Progress Planner Onboarding Wizard
+ * Handles the onboarding wizard functionality
+ *
+ * Dependencies: progress-planner/license-generator
+ */
+/* global ProgressPlannerOnboardData */
+
+// eslint-disable-next-line no-unused-vars
+class ProgressPlannerOnboardWizard {
+ constructor( config ) {
+ this.config = config;
+ this.state = {
+ currentStep: 0,
+ data: {
+ moreTasksCompleted: {},
+ firstTaskCompleted: false,
+ finished: false,
+ },
+ cleanup: null,
+ };
+
+ // Store previously focused element for accessibility
+ this.previouslyFocusedElement = null;
+
+ // Restore saved progress if available
+ this.restoreSavedProgress();
+
+ // Make state work with reactive updates.
+ this.setupStateProxy();
+
+ // Set DOM related properties FIRST.
+ this.popover = document.getElementById( this.config.popoverId );
+ this.contentWrapper = this.popover.querySelector(
+ '.tour-content-wrapper'
+ );
+
+ // Popover buttons.
+ this.closeBtn = this.popover.querySelector( '#prpl-tour-close-btn' );
+
+ // Initialize tour steps AFTER popover is set
+ this.tourSteps = this.initializeTourSteps();
+
+ // Setup event listeners after DOM is ready
+ this.setupEventListeners();
+ }
+
+ /**
+ * Restore saved progress from server
+ */
+ restoreSavedProgress() {
+ if (
+ ! this.config.savedProgress ||
+ typeof this.config.savedProgress !== 'object'
+ ) {
+ return;
+ }
+
+ const savedState = this.config.savedProgress;
+
+ // Restore currentStep if valid
+ if (
+ typeof savedState.currentStep === 'number' &&
+ savedState.currentStep >= 0
+ ) {
+ this.state.currentStep = savedState.currentStep;
+ console.log(
+ 'Restored onboarding progress to step:',
+ this.state.currentStep
+ );
+ }
+
+ // Restore data object if present
+ if ( savedState.data && typeof savedState.data === 'object' ) {
+ // Merge saved data with default state
+ this.state.data = {
+ ...this.state.data,
+ ...savedState.data,
+ };
+
+ // Ensure moreTasksCompleted is an object
+ if (
+ ! this.state.data.moreTasksCompleted ||
+ typeof this.state.data.moreTasksCompleted !== 'object'
+ ) {
+ this.state.data.moreTasksCompleted = {};
+ }
+
+ console.log( 'Restored onboarding data:', this.state.data );
+ }
+ }
+
+ /**
+ * Initialize tour steps configuration
+ * Creates instances of step components
+ */
+ initializeTourSteps() {
+ // Create instances of step components.
+ const steps = this.config.steps.map( ( stepName ) => {
+ if (
+ window[ `Prpl${ stepName }` ] &&
+ typeof window[ `Prpl${ stepName }` ] === 'object'
+ ) {
+ return window[ `Prpl${ stepName }` ];
+ }
+
+ console.error(
+ `Step class "${ stepName }" not found. Available on window:`,
+ Object.keys( window ).filter( ( key ) =>
+ key.includes( 'Step' )
+ )
+ );
+
+ return null;
+ } );
+
+ // Set wizard reference for each step
+ steps.forEach( ( step ) => step.setWizard( this ) );
+
+ return steps;
+ }
+
+ /**
+ * Render current step
+ */
+ renderStep() {
+ const step = this.tourSteps[ this.state.currentStep ];
+
+ // Render step content
+ this.contentWrapper.innerHTML = step.render();
+
+ // Cleanup previous step
+ if ( this.state.cleanup ) {
+ this.state.cleanup();
+ this.state.cleanup = null;
+ }
+
+ // Mount current step and store cleanup function
+ this.state.cleanup = step.onMount( this.state );
+
+ // Setup next button (handled by step now)
+ step.setupNextButton();
+
+ // Update step indicator
+ this.popover.dataset.prplStep = this.state.currentStep;
+ this.updateStepNavigation();
+ }
+
+ /**
+ * Update step navigation in left column
+ */
+ updateStepNavigation() {
+ const stepItems = this.popover.querySelectorAll(
+ '.prpl-nav-step-item'
+ );
+ let activeStepTitle = '';
+
+ stepItems.forEach( ( item, index ) => {
+ const icon = item.querySelector( '.prpl-step-icon' );
+ const stepNumber = index + 1;
+
+ // Remove all state classes
+ item.classList.remove( 'prpl-active', 'prpl-completed' );
+
+ // Add appropriate class and update icon
+ if ( index < this.state.currentStep ) {
+ // Completed step: show checkmark
+ item.classList.add( 'prpl-completed' );
+ icon.textContent = 'β';
+ } else if ( index === this.state.currentStep ) {
+ // Active step: show number
+ item.classList.add( 'prpl-active' );
+ icon.textContent = stepNumber;
+ activeStepTitle =
+ item.querySelector( '.prpl-step-label' ).textContent;
+ } else {
+ // Future step: show number
+ icon.textContent = stepNumber;
+ }
+ } );
+
+ // Update mobile step label
+ const mobileStepLabel = this.popover.querySelector(
+ '#prpl-onboarding-mobile-step-label'
+ );
+ if ( mobileStepLabel ) {
+ mobileStepLabel.textContent = activeStepTitle;
+ }
+ }
+
+ /**
+ * Move to next step
+ */
+ async nextStep() {
+ console.log(
+ 'nextStep() called, current step:',
+ this.state.currentStep
+ );
+ const step = this.tourSteps[ this.state.currentStep ];
+
+ // Check if user can proceed from current step
+ if ( ! step.canProceed( this.state ) ) {
+ console.log( 'Cannot proceed - step requirements not met' );
+ return;
+ }
+
+ // Call beforeNextStep if step has it (for async operations like license generation)
+ if ( step.beforeNextStep ) {
+ try {
+ await step.beforeNextStep();
+ } catch ( error ) {
+ console.error( 'Error in beforeNextStep:', error );
+ return; // Don't proceed if beforeNextStep fails
+ }
+ }
+
+ if ( this.state.currentStep < this.tourSteps.length - 1 ) {
+ this.state.currentStep++;
+ console.log( 'Moving to step:', this.state.currentStep );
+ this.saveProgressToServer();
+ this.renderStep();
+ } else {
+ console.log( 'Finishing tour - reached last step' );
+ this.state.data.finished = true;
+ this.closeTour();
+
+ // Redirect to the Progress Planner dashboard
+ if (
+ this.config.lastStepRedirectUrl &&
+ this.config.lastStepRedirectUrl.length > 0
+ ) {
+ window.location.href = this.config.lastStepRedirectUrl;
+ }
+ }
+ }
+
+ /**
+ * Move to previous step, currently not used.
+ */
+ prevStep() {
+ if ( this.state.currentStep > 0 ) {
+ this.state.currentStep--;
+ this.renderStep();
+ }
+ }
+
+ /**
+ * Close the tour
+ */
+ closeTour() {
+ if ( this.popover ) {
+ this.popover.hidePopover();
+ }
+ this.saveProgressToServer();
+
+ // Cleanup active step
+ if ( this.state.cleanup ) {
+ this.state.cleanup();
+ }
+
+ // Reset cleanup
+ this.state.cleanup = null;
+
+ // Restore focus to previously focused element for accessibility
+ if (
+ this.previouslyFocusedElement &&
+ typeof this.previouslyFocusedElement.focus === 'function'
+ ) {
+ this.previouslyFocusedElement.focus();
+ this.previouslyFocusedElement = null;
+ }
+ }
+
+ /**
+ * Start the onboarding
+ */
+ startOnboarding() {
+ if ( this.popover ) {
+ // Store currently focused element for accessibility
+ this.previouslyFocusedElement =
+ this.popover.ownerDocument.activeElement;
+
+ this.popover.showPopover();
+ this.updateStepNavigation();
+ this.renderStep();
+
+ // Move focus to popover for keyboard accessibility
+ // Use setTimeout to ensure popover is visible before focusing
+ setTimeout( () => {
+ this.popover.focus();
+ }, 0 );
+ }
+ }
+
+ /**
+ * Save progress to server
+ */
+ async saveProgressToServer() {
+ try {
+ const response = await fetch( this.config.adminAjaxUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams( {
+ state: JSON.stringify( this.state ),
+ nonce: this.config.nonceProgressPlanner,
+ action: 'progress_planner_onboarding_save_progress',
+ } ),
+ credentials: 'same-origin',
+ } );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Request failed: ' + response.status );
+ }
+
+ return response.json();
+ } catch ( error ) {
+ console.error( 'Failed to save tour progress:', error );
+ }
+ }
+
+ /**
+ * Update next button state
+ * Delegates to current step's updateNextButton method
+ */
+ updateNextButton() {
+ const step = this.tourSteps[ this.state.currentStep ];
+ if ( step && typeof step.updateNextButton === 'function' ) {
+ step.updateNextButton();
+ }
+ }
+
+ /**
+ * Update DOM, used for reactive updates.
+ * All changes which should happen when the state changes should be done here.
+ */
+ updateDOM() {
+ this.updateNextButton();
+ }
+
+ /**
+ * Setup event listeners
+ */
+ setupEventListeners() {
+ console.log( 'Setting up event listeners...' );
+ if ( this.popover ) {
+ console.log( 'Popover found:', this.popover );
+
+ this.popover.addEventListener( 'beforetoggle', ( event ) => {
+ if ( event.newState === 'open' ) {
+ console.log( 'Tour opened' );
+ }
+ if ( event.newState === 'closed' ) {
+ console.log( 'Tour closed' );
+ }
+ } );
+
+ // Note: nextBtn click handler is now set up in renderStep()
+ // since the button is part of the step content
+
+ if ( this.closeBtn ) {
+ this.closeBtn.addEventListener( 'click', ( e ) => {
+ console.log( 'Close button clicked!' );
+
+ // Display quit confirmation if on welcome step (since privacy policy is accepted there)
+ if ( this.state.currentStep === 0 ) {
+ e.preventDefault();
+ this.showQuitConfirmation();
+ return;
+ }
+
+ this.state.data.finished =
+ this.state.currentStep === this.tourSteps.length - 1;
+ this.closeTour();
+
+ // If on PP Dashboard page and privacy was accepted during onboarding,
+ // refresh the page to properly initialize dashboard components.
+ if (
+ this.state.data.privacyAccepted &&
+ window.location.href.includes(
+ 'admin.php?page=progress-planner'
+ )
+ ) {
+ window.location.reload();
+ }
+ } );
+ }
+ } else {
+ console.error( 'Popover not found!' );
+ }
+ }
+
+ /**
+ * Show quit confirmation when trying to close without accepting privacy
+ */
+ showQuitConfirmation() {
+ // Replace content with confirmation message
+ const originalContent = this.contentWrapper.innerHTML;
+
+ // Get template from DOM
+ const template = document.getElementById(
+ 'prpl-onboarding-quit-confirmation'
+ );
+ if ( ! template ) {
+ console.error( 'Quit confirmation template not found' );
+ return;
+ }
+
+ this.contentWrapper.innerHTML = template.innerHTML;
+
+ // Add event listeners
+ const quitYes = this.contentWrapper.querySelector( '#prpl-quit-yes' );
+ const quitNo = this.contentWrapper.querySelector( '#prpl-quit-no' );
+
+ if ( quitYes ) {
+ quitYes.addEventListener( 'click', ( e ) => {
+ e.preventDefault();
+ this.closeTour();
+ } );
+ }
+
+ if ( quitNo ) {
+ quitNo.addEventListener( 'click', ( e ) => {
+ e.preventDefault();
+ // Restore original content
+ this.contentWrapper.innerHTML = originalContent;
+
+ // Re-mount the step
+ this.renderStep();
+ } );
+ }
+ }
+
+ /**
+ * Setup state proxy for reactive updates
+ */
+ setupStateProxy() {
+ this.state.data = this.createDeepProxy( this.state.data, () =>
+ this.updateDOM()
+ );
+ }
+
+ /**
+ * Create deep proxy for nested object changes
+ * @param {Object} target
+ * @param {Function} callback
+ */
+ createDeepProxy( target, callback ) {
+ // Recursively wrap existing nested objects first
+ for ( const key of Object.keys( target ) ) {
+ if (
+ target[ key ] &&
+ typeof target[ key ] === 'object' &&
+ ! Array.isArray( target[ key ] )
+ ) {
+ target[ key ] = this.createDeepProxy( target[ key ], callback );
+ }
+ }
+
+ return new Proxy( target, {
+ set: ( obj, prop, value ) => {
+ if (
+ value &&
+ typeof value === 'object' &&
+ ! Array.isArray( value )
+ ) {
+ value = this.createDeepProxy( value, callback );
+ }
+ obj[ prop ] = value;
+ callback();
+ return true;
+ },
+ } );
+ }
+}
+
+class ProgressPlannerTourUtils {
+ /**
+ * Complete a task via AJAX
+ * @param {string} taskId
+ * @param {Object} formValues
+ */
+ static async completeTask( taskId, formValues = {} ) {
+ const response = await fetch( ProgressPlannerOnboardData.adminAjaxUrl, {
+ method: 'POST',
+ body: new URLSearchParams( {
+ form_values: JSON.stringify( formValues ),
+ task_id: taskId,
+ nonce: ProgressPlannerOnboardData.nonceProgressPlanner,
+ action: 'progress_planner_onboarding_complete_task',
+ } ),
+ } );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Request failed: ' + response.status );
+ }
+
+ return response.json();
+ }
+}
diff --git a/assets/js/onboarding/steps/BadgesStep.js b/assets/js/onboarding/steps/BadgesStep.js
new file mode 100644
index 0000000000..516636fadb
--- /dev/null
+++ b/assets/js/onboarding/steps/BadgesStep.js
@@ -0,0 +1,62 @@
+/**
+ * Badges step - Explains the badge system to users
+ * Simple informational step with no user interaction required
+ */
+/* global OnboardingStep */
+
+class PrplBadgesStep extends OnboardingStep {
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-badges',
+ } );
+ }
+
+ /**
+ * Mount badges step and lazy-load badge graphic
+ * Badge is only loaded after privacy policy is accepted
+ * @return {Function} Cleanup function
+ */
+ onMount() {
+ const gaugeElement = document.getElementById( 'prpl-gauge-onboarding' );
+
+ if ( ! gaugeElement ) {
+ return () => {};
+ }
+
+ // Create badge element using innerHTML to properly instantiate the custom element
+ const badgeId = gaugeElement.getAttribute( 'data-badge-id' );
+ const badgeName = gaugeElement.getAttribute( 'data-badge-name' );
+ const brandingId = gaugeElement.getAttribute( 'data-branding-id' );
+
+ gaugeElement.innerHTML = `
+
+ `;
+
+ // Increment badge point(s) after badge is loaded
+ setTimeout( () => {
+ if ( gaugeElement ) {
+ // Check if the first task was completed.
+ if ( this.wizard.state.data.firstTaskCompleted ) {
+ gaugeElement.value += 1;
+ }
+ }
+ }, 1500 );
+
+ return () => {};
+ }
+
+ /**
+ * User can always proceed from badges step
+ * @return {boolean} Always returns true
+ */
+ canProceed() {
+ return true;
+ }
+}
+
+window.PrplBadgesStep = new PrplBadgesStep();
diff --git a/assets/js/onboarding/steps/EmailFrequencyStep.js b/assets/js/onboarding/steps/EmailFrequencyStep.js
new file mode 100644
index 0000000000..ff9ffc56ea
--- /dev/null
+++ b/assets/js/onboarding/steps/EmailFrequencyStep.js
@@ -0,0 +1,208 @@
+/**
+ * Email Frequency step - Allow users to opt in/out of weekly emails
+ * If opted in, collects name and email for subscription
+ */
+/* global OnboardingStep, ProgressPlannerOnboardData, LicenseGenerator */
+
+class PrplEmailFrequencyStep extends OnboardingStep {
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-email-frequency',
+ } );
+ }
+
+ /**
+ * Mount the email frequency step
+ * Sets up radio button and form field listeners
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ const emailWeeklyRadio =
+ this.popover.querySelector( '#prpl-email-weekly' );
+ const dontEmailRadio = this.popover.querySelector( '#prpl-dont-email' );
+ const emailForm = this.popover.querySelector( '#prpl-email-form' );
+ const nameInput = this.popover.querySelector( '#prpl-email-name' );
+ const emailInput = this.popover.querySelector( '#prpl-email-address' );
+
+ if ( ! emailWeeklyRadio || ! dontEmailRadio || ! emailForm ) {
+ return () => {};
+ }
+
+ // Initialize state
+ if ( ! state.data.emailFrequency ) {
+ state.data.emailFrequency = {
+ choice: 'weekly', // Default to 'weekly'
+ name: nameInput ? nameInput.value.trim() : '', // Get pre-populated value
+ email: emailInput ? emailInput.value.trim() : '', // Get pre-populated value
+ };
+ }
+
+ // Set radio button state from wizard state
+ if ( state.data.emailFrequency.choice === 'weekly' ) {
+ emailWeeklyRadio.checked = true;
+ emailForm.style.display = 'block';
+ } else if ( state.data.emailFrequency.choice === 'none' ) {
+ dontEmailRadio.checked = true;
+ emailForm.style.display = 'none';
+ }
+
+ // Set form values from state (or keep pre-populated values)
+ if ( nameInput ) {
+ nameInput.value = state.data.emailFrequency.name || nameInput.value;
+ }
+ if ( emailInput ) {
+ emailInput.value =
+ state.data.emailFrequency.email || emailInput.value;
+ }
+
+ // Radio button change handlers
+ const weeklyHandler = ( e ) => {
+ if ( e.target.checked ) {
+ state.data.emailFrequency.choice = 'weekly';
+ emailForm.style.display = 'block';
+
+ // Update button state
+ this.updateNextButton();
+ }
+ };
+
+ const dontEmailHandler = ( e ) => {
+ if ( e.target.checked ) {
+ state.data.emailFrequency.choice = 'none';
+ emailForm.style.display = 'none';
+
+ // Update button state
+ this.updateNextButton();
+ }
+ };
+
+ // Form input handlers
+ const nameHandler = ( e ) => {
+ state.data.emailFrequency.name = e.target.value.trim();
+ this.updateNextButton();
+ };
+
+ const emailHandler = ( e ) => {
+ state.data.emailFrequency.email = e.target.value.trim();
+ this.updateNextButton();
+ };
+
+ // Add event listeners
+ emailWeeklyRadio.addEventListener( 'change', weeklyHandler );
+ dontEmailRadio.addEventListener( 'change', dontEmailHandler );
+
+ if ( nameInput ) {
+ nameInput.addEventListener( 'input', nameHandler );
+ }
+ if ( emailInput ) {
+ emailInput.addEventListener( 'input', emailHandler );
+ }
+
+ // Cleanup function
+ return () => {
+ emailWeeklyRadio.removeEventListener( 'change', weeklyHandler );
+ dontEmailRadio.removeEventListener( 'change', dontEmailHandler );
+
+ if ( nameInput ) {
+ nameInput.removeEventListener( 'input', nameHandler );
+ }
+ if ( emailInput ) {
+ emailInput.removeEventListener( 'input', emailHandler );
+ }
+ };
+ }
+
+ /**
+ * User can proceed if:
+ * - "Don't email me" is selected, OR
+ * - "Email me weekly" is selected AND both name and email fields are filled
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if can proceed
+ */
+ canProceed( state ) {
+ // Initialize state if needed (defensive check)
+ if ( ! state.data.emailFrequency ) {
+ state.data.emailFrequency = {
+ choice: null,
+ name: '',
+ email: '',
+ };
+ }
+
+ const emailFrequency = state.data.emailFrequency;
+
+ if ( ! emailFrequency.choice ) {
+ return false;
+ }
+
+ // If user chose "don't email", they can proceed immediately
+ if ( emailFrequency.choice === 'none' ) {
+ return true;
+ }
+
+ // If user chose "weekly", check that name and email are filled
+ if ( emailFrequency.choice === 'weekly' ) {
+ return !! ( emailFrequency.name && emailFrequency.email );
+ }
+
+ return false;
+ }
+
+ /**
+ * Called before advancing to next step
+ * Fires AJAX request to subscribe user if "Email me weekly" was selected
+ * @return {Promise} Resolves when action is complete
+ */
+ async beforeNextStep() {
+ const state = this.getState();
+
+ // Only send AJAX if user chose to receive emails
+ if ( state.data.emailFrequency.choice !== 'weekly' ) {
+ return Promise.resolve();
+ }
+
+ // Show spinner
+ const spinner = this.showSpinner( this.nextBtn );
+
+ try {
+ // Use LicenseGenerator to handle the license generation process
+ await LicenseGenerator.generateLicense(
+ {
+ name: state.data.emailFrequency.name,
+ email: state.data.emailFrequency.email,
+ site: ProgressPlannerOnboardData.site,
+ timezone_offset: ProgressPlannerOnboardData.timezone_offset,
+ 'with-email': 'yes',
+ },
+ {
+ onboardNonceURL: ProgressPlannerOnboardData.onboardNonceURL,
+ onboardAPIUrl: ProgressPlannerOnboardData.onboardAPIUrl,
+ adminAjaxUrl: ProgressPlannerOnboardData.adminAjaxUrl,
+ nonce: ProgressPlannerOnboardData.nonceProgressPlanner,
+ }
+ );
+
+ console.log( 'Successfully subscribed' );
+ } catch ( error ) {
+ console.error( 'Failed to subscribe:', error );
+
+ // Display error message to user
+ this.showErrorMessage(
+ error.message || 'Failed to subscribe. Please try again.',
+ 'Error subscribing'
+ );
+
+ // Re-enable the button so user can retry
+ this.setNextButtonDisabled( false );
+
+ // Don't proceed to next step
+ throw error;
+ } finally {
+ // Remove spinner
+ spinner.remove();
+ }
+ }
+}
+
+window.PrplEmailFrequencyStep = new PrplEmailFrequencyStep();
diff --git a/assets/js/onboarding/steps/FirstTaskStep.js b/assets/js/onboarding/steps/FirstTaskStep.js
new file mode 100644
index 0000000000..633f109432
--- /dev/null
+++ b/assets/js/onboarding/steps/FirstTaskStep.js
@@ -0,0 +1,68 @@
+/**
+ * First Task step - User completes their first task
+ * Handles task completion and form submission
+ */
+/* global OnboardingStep, ProgressPlannerTourUtils */
+class PrplFirstTaskStep extends OnboardingStep {
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-first-task',
+ } );
+ }
+
+ /**
+ * Mount the first task step
+ * Sets up event listener for task completion
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ const btn = this.popover.querySelector( '.prpl-complete-task-btn' );
+ if ( ! btn ) {
+ return () => {};
+ }
+
+ const handler = ( e ) => {
+ const thisBtn = e.target.closest( 'button' );
+ const form = thisBtn.closest( 'form' );
+ let formValues = {};
+
+ if ( form ) {
+ const formData = new FormData( form );
+ formValues = Object.fromEntries( formData.entries() );
+ }
+
+ ProgressPlannerTourUtils.completeTask(
+ thisBtn.dataset.taskId,
+ formValues
+ )
+ .then( () => {
+ thisBtn.classList.add( 'prpl-complete-task-btn-completed' );
+ this.updateState( 'firstTaskCompleted', {
+ [ thisBtn.dataset.taskId ]: true,
+ } );
+
+ // Automatically advance to the next step
+ this.nextStep();
+ } )
+ .catch( ( error ) => {
+ console.error( error );
+ thisBtn.classList.add( 'prpl-complete-task-btn-error' );
+ } );
+ };
+
+ btn.addEventListener( 'click', handler );
+ return () => btn.removeEventListener( 'click', handler );
+ }
+
+ /**
+ * User can only proceed if they've completed the first task
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if first task is completed
+ */
+ canProceed( state ) {
+ return !! state.data.firstTaskCompleted;
+ }
+}
+
+window.PrplFirstTaskStep = new PrplFirstTaskStep();
diff --git a/assets/js/onboarding/steps/MoreTasksStep.js b/assets/js/onboarding/steps/MoreTasksStep.js
new file mode 100644
index 0000000000..998d943ade
--- /dev/null
+++ b/assets/js/onboarding/steps/MoreTasksStep.js
@@ -0,0 +1,143 @@
+/**
+ * More Tasks step - User completes additional tasks
+ * Handles multiple tasks that can be completed in any order
+ * Each task may open a sub-popover with its own form
+ * Split into 2 substeps: intro screen and task list
+ */
+/* global OnboardingStep, PrplOnboardTask */
+
+class PrplMoreTasksStep extends OnboardingStep {
+ subSteps = [ 'more-tasks-intro', 'more-tasks-tasks' ];
+
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-more-tasks',
+ } );
+ this.tasks = [];
+ this.currentSubStep = 0;
+ }
+
+ /**
+ * Mount the more tasks step
+ * Initializes all tasks and sets up event listeners
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ // Always start from first sub-step
+ this.currentSubStep = 0;
+
+ // Hide footer initially (will show on tasks substep)
+ this.toggleStepFooter( false );
+
+ // Render the current sub-step
+ this.renderSubStep( state );
+
+ // Setup continue button listener
+ const continueBtn = this.popover.querySelector(
+ '.prpl-more-tasks-continue'
+ );
+ if ( continueBtn ) {
+ continueBtn.addEventListener( 'click', () => {
+ this.advanceSubStep( state );
+ } );
+ }
+
+ // Setup finish onboarding link in intro
+ const finishLink = this.popover.querySelector(
+ '.prpl-more-tasks-substep[data-substep="intro"] .prpl-finish-onboarding'
+ );
+ if ( finishLink ) {
+ finishLink.addEventListener( 'click', ( e ) => {
+ e.preventDefault();
+ this.wizard.finishOnboarding();
+ } );
+ }
+
+ // Initialize task completion tracking
+ const moreTasks = this.popover.querySelectorAll(
+ '.prpl-task-item[data-task-id]'
+ );
+ moreTasks.forEach( ( btn ) => {
+ if ( ! state.data.moreTasksCompleted ) {
+ state.data.moreTasksCompleted = {};
+ }
+ state.data.moreTasksCompleted[ btn.dataset.taskId ] = false;
+ } );
+
+ // Initialize PrplOnboardTask instances for each task, passing wizard reference
+ this.tasks = Array.from(
+ this.popover.querySelectorAll( '[data-popover="task"]' )
+ ).map( ( t ) => new PrplOnboardTask( t, this.wizard ) );
+
+ // Listen for task completion events
+ const handler = ( e ) => {
+ // Update state when a task is completed
+ state.data.moreTasksCompleted[ e.detail.id ] = true;
+
+ // Update next button state
+ this.updateNextButton();
+ };
+
+ this.popover.addEventListener( 'taskCompleted', handler );
+
+ // Return cleanup function
+ return () => {
+ this.popover.removeEventListener( 'taskCompleted', handler );
+ // Clean up task instances
+ this.tasks = [];
+ // Show footer when leaving this step
+ this.toggleStepFooter( true );
+ };
+ }
+
+ /**
+ * Render the current sub-step
+ * @param {Object} state - Wizard state
+ */
+ renderSubStep( state ) {
+ const subStepName = this.subSteps[ this.currentSubStep ];
+
+ // Show/hide sub-step containers
+ this.subSteps.forEach( ( step ) => {
+ const container = this.popover.querySelector(
+ `.prpl-more-tasks-substep[data-substep="${ step }"]`
+ );
+ if ( container ) {
+ container.style.display = step === subStepName ? '' : 'none';
+ }
+ } );
+
+ // Show footer only on tasks substep
+ const isTasksSubStep = subStepName === 'more-tasks-tasks';
+ this.toggleStepFooter( isTasksSubStep );
+
+ // Update Next button state if on tasks sub-step
+ if ( isTasksSubStep ) {
+ this.updateNextButton();
+ }
+ }
+
+ /**
+ * Advance to next sub-step
+ * @param {Object} state - Wizard state
+ */
+ advanceSubStep( state ) {
+ if ( this.currentSubStep < this.subSteps.length - 1 ) {
+ this.currentSubStep++;
+ this.renderSubStep( state );
+ }
+ }
+
+ /**
+ * User can only proceed if on tasks substep
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if can proceed
+ */
+ canProceed( state ) {
+ // Can only proceed if on tasks substep
+ return this.currentSubStep === this.subSteps.length - 1;
+ }
+}
+
+window.PrplMoreTasksStep = new PrplMoreTasksStep();
diff --git a/assets/js/onboarding/steps/OnboardingStep.js b/assets/js/onboarding/steps/OnboardingStep.js
new file mode 100644
index 0000000000..f75281b630
--- /dev/null
+++ b/assets/js/onboarding/steps/OnboardingStep.js
@@ -0,0 +1,326 @@
+/**
+ * Base class for onboarding steps
+ * All step components should extend this class
+ */
+class OnboardingStep {
+ /**
+ * Constructor
+ * @param {Object} config - Step configuration
+ * @param {string} config.id - Unique step identifier
+ * @param {string} config.templateId - ID of the template element containing the step HTML
+ */
+ constructor( config ) {
+ this.templateId = config.templateId;
+ this.wizard = null; // Reference to parent wizard
+ this.popover = null; // Reference to popover element
+ this.cleanup = null; // Cleanup function for event listeners
+ this.nextBtn = null; // Reference to next button element
+ }
+
+ /**
+ * Set wizard reference
+ * @param {ProgressPlannerOnboardWizard} wizard
+ */
+ setWizard( wizard ) {
+ this.wizard = wizard;
+ this.popover = wizard.popover;
+ }
+
+ /**
+ * Get the step's HTML content
+ * @return {string} HTML content
+ */
+ render() {
+ const template = document.getElementById( this.templateId );
+ if ( ! template ) {
+ console.error( `Template not found: ${ this.templateId }` );
+ return '';
+ }
+ return template.innerHTML;
+ }
+
+ /**
+ * Called when step is mounted to DOM
+ * Override this method to setup event listeners and step-specific logic
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function to be called when step unmounts
+ */
+ onMount( state ) {
+ // Override in subclass
+ return () => {};
+ }
+
+ /**
+ * Check if user can proceed to next step
+ * Override this method to add step-specific validation
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if user can proceed
+ */
+ canProceed( state ) {
+ // Override in subclass
+ return true;
+ }
+
+ /**
+ * Called when step is about to be unmounted
+ * Override this method for cleanup logic
+ */
+ onUnmount() {
+ if ( this.cleanup ) {
+ this.cleanup();
+ this.cleanup = null;
+ }
+ }
+
+ /**
+ * Utility method to update wizard state
+ * @param {string} key - State key to update
+ * @param {*} value - New value
+ */
+ updateState( key, value ) {
+ if ( this.wizard ) {
+ this.wizard.state.data[ key ] = value;
+ }
+ }
+
+ /**
+ * Utility method to get current state
+ * @return {Object} Current wizard state
+ */
+ getState() {
+ return this.wizard ? this.wizard.state : null;
+ }
+
+ /**
+ * Utility method to advance to next step
+ */
+ nextStep() {
+ if ( this.wizard ) {
+ this.wizard.nextStep();
+ }
+ }
+
+ /**
+ * Get the tour footer element
+ * @return {HTMLElement|null} The tour footer element or null if not found
+ */
+ getTourFooter() {
+ return this.wizard?.contentWrapper?.querySelector( '.tour-footer' );
+ }
+
+ /**
+ * Show error message to user
+ * @param {string} message Error message to display
+ * @param {string} title Optional error title
+ */
+ showErrorMessage( message, title = '' ) {
+ // Remove existing error if any
+ this.clearErrorMessage();
+
+ // Build title HTML if provided
+ const titleHtml = title ? `${ this.escapeHtml( title ) } ` : '';
+
+ // Get error icon from wizard config
+ const errorIcon = this.wizard?.config?.errorIcon || '';
+
+ // Create error message element
+ const errorDiv = document.createElement( 'div' );
+ errorDiv.className = 'prpl-error-message';
+ errorDiv.innerHTML = `
+
+
+ ${ errorIcon }
+
+
+ ${ titleHtml }
+
${ this.escapeHtml( message ) }
+
+
+ `;
+
+ // Add error message to tour footer
+ const footer = this.getTourFooter();
+ if ( footer ) {
+ footer.prepend( errorDiv );
+ }
+ }
+
+ /**
+ * Clear error message
+ */
+ clearErrorMessage() {
+ const existingError = this.wizard?.popover?.querySelector(
+ '.prpl-error-message'
+ );
+ if ( existingError ) {
+ existingError.remove();
+ }
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ * @param {string} text Text to escape
+ * @return {string} Escaped text
+ */
+ escapeHtml( text ) {
+ const div = document.createElement( 'div' );
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Show spinner before a button and disable the button
+ * @param {HTMLElement} button Button element to show spinner before and disable
+ * @return {HTMLElement} The created spinner element
+ */
+ showSpinner( button ) {
+ const spinner = document.createElement( 'span' );
+ spinner.classList.add( 'prpl-spinner' );
+ spinner.innerHTML =
+ ' ';
+
+ button.parentElement.insertBefore( spinner, button );
+ button.disabled = true;
+
+ return spinner;
+ }
+
+ /**
+ * Toggle visibility of the footer in this step's template
+ * @param {boolean} visible - Whether to show the footer
+ */
+ toggleStepFooter( visible ) {
+ const stepFooter = this.getTourFooter();
+ if ( stepFooter ) {
+ stepFooter.style.display = visible ? 'flex' : 'none';
+ }
+ }
+
+ /**
+ * Called before advancing to next step
+ * Fires AJAX request to subscribe user if "Email me weekly" was selected
+ * @return {Promise} Resolves when action is complete
+ */
+ async beforeNextStep() {
+ // Override in subclass
+ return Promise.resolve();
+ }
+
+ /**
+ * Setup next button after step is rendered
+ * Finds button, attaches click handler, and initializes state
+ * Called automatically by wizard after rendering
+ */
+ setupNextButton() {
+ // Find the next button in the rendered step content
+ this.nextBtn =
+ this.wizard?.contentWrapper?.querySelector( '.prpl-tour-next' );
+
+ if ( ! this.nextBtn ) {
+ // Step doesn't have a next button (e.g., SettingsStep with sub-steps)
+ return;
+ }
+
+ // Remove any existing listeners by cloning the button
+ const newBtn = this.nextBtn.cloneNode( true );
+ if ( this.nextBtn.parentNode ) {
+ this.nextBtn.parentNode.replaceChild( newBtn, this.nextBtn );
+ }
+ this.nextBtn = newBtn;
+
+ // Add click listener
+ this.nextBtn.addEventListener( 'click', () => {
+ console.log( 'Next button clicked!' );
+ this.nextStep();
+ } );
+
+ // Initialize button state
+ this.updateNextButton();
+
+ // Call hook for subclasses to add custom button behavior
+ // Returns optional cleanup function
+ const customCleanup = this.onNextButtonSetup();
+
+ // If step provided a cleanup function, chain it with existing cleanup
+ if ( customCleanup && typeof customCleanup === 'function' ) {
+ const originalCleanup = this.cleanup;
+ this.cleanup = () => {
+ customCleanup();
+ if ( originalCleanup ) {
+ originalCleanup();
+ }
+ };
+ }
+ }
+
+ /**
+ * Called after next button is setup
+ * Override to add custom button behavior
+ * @return {Function|void} Optional cleanup function
+ */
+ onNextButtonSetup() {
+ // Override in subclass
+ // Return a cleanup function if you need to remove event listeners
+ }
+
+ /**
+ * Update next button state (text and enabled/disabled)
+ * Called when step state changes
+ */
+ updateNextButton() {
+ if ( ! this.nextBtn ) {
+ return;
+ }
+
+ const state = this.getState();
+ const canProceed = this.canProceed( state );
+
+ // Update enabled/disabled state
+ this.setNextButtonDisabled( ! canProceed );
+
+ // Update button text
+ this.updateNextButtonText();
+ }
+
+ /**
+ * Update next button text based on step configuration and wizard state
+ * Currently this is only used to change button text on the last step to "Take me to the Recommendations dashboard"
+ */
+ updateNextButtonText() {
+ if ( ! this.nextBtn || ! this.wizard ) {
+ return;
+ }
+
+ const isLastStep =
+ this.wizard.state.currentStep === this.wizard.tourSteps.length - 1;
+
+ // Check if step provides custom button text
+ if ( isLastStep ) {
+ // On last step, use "Take me to the Recommendations dashboard" text
+ const dashboardText =
+ this.wizard.config?.l10n?.dashboard ||
+ 'Take me to the Recommendations dashboard';
+ this.nextBtn.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
new file mode 100644
index 0000000000..8986db6e74
--- /dev/null
+++ b/assets/js/onboarding/steps/SettingsStep.js
@@ -0,0 +1,492 @@
+/**
+ * Settings step - Configure About, Contact, FAQ pages, and Post Types
+ * Multi-step process with 5 sub-steps
+ */
+/* global OnboardingStep, ProgressPlannerOnboardData */
+
+class PrplSettingsStep extends OnboardingStep {
+ subSteps = [ 'homepage', 'about', 'contact', 'faq', 'post-types' ];
+
+ defaultSettings = {
+ homepage: {
+ hasPage: true, // true if checkbox is NOT checked (default: unchecked)
+ pageId: null,
+ },
+ about: {
+ hasPage: true, // true if checkbox is NOT checked (default: unchecked)
+ pageId: null,
+ },
+ contact: {
+ hasPage: true,
+ pageId: null,
+ },
+ faq: {
+ hasPage: true,
+ pageId: null,
+ },
+ 'post-types': {
+ selectedTypes: [], // Array of selected post type slugs
+ },
+ };
+
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-settings',
+ } );
+ this.currentSubStep = 0;
+ }
+
+ /**
+ * Mount the settings step
+ * Sets up event listeners for page select and save button
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ // Initialize state
+ if ( ! state.data.settings ) {
+ state.data.settings = {};
+ }
+
+ // Initialize missing sub-steps
+ for ( const [ key, defaultValue ] of Object.entries(
+ this.defaultSettings
+ ) ) {
+ if ( ! state.data.settings[ key ] ) {
+ state.data.settings[ key ] = { ...defaultValue };
+ }
+ }
+
+ // Always start from first sub-step
+ this.currentSubStep = 0;
+
+ // Hide footer in step template initially (will show on last sub-step)
+ this.toggleStepFooter( false );
+
+ // Render the current sub-step
+ this.renderSubStep( state );
+
+ // Return cleanup function
+ return () => {
+ // Show footer when leaving this step (for other steps that might need it)
+ this.toggleStepFooter( true );
+ };
+ }
+
+ /**
+ * Render the current sub-step
+ * @param {Object} state - Wizard state
+ */
+ renderSubStep( state ) {
+ const subStepName = this.subSteps[ this.currentSubStep ];
+ const subStepData = state.data.settings[ subStepName ];
+
+ // Update progress indicator
+ /*
+ const progressIndicator = this.popover.querySelector(
+ '.prpl-settings-progress'
+ );
+ if ( progressIndicator ) {
+ progressIndicator.textContent = `${ this.currentSubStep + 1 }/${
+ this.subSteps.length
+ }`;
+ }
+ */
+
+ // Show/hide sub-step containers
+ this.subSteps.forEach( ( step, index ) => {
+ const container = this.popover.querySelector(
+ `.prpl-setting-item[data-page="${ step }"]`
+ );
+ if ( container ) {
+ container.style.display =
+ index === this.currentSubStep ? 'flex' : 'none';
+ }
+ } );
+
+ // Hide "Save setting" button on last sub-step (show Next/Dashboard instead)
+ const isLastSubStep = this.currentSubStep === this.subSteps.length - 1;
+ const saveButton = this.popover.querySelector(
+ `#prpl-save-${ subStepName }-setting`
+ );
+ if ( saveButton ) {
+ saveButton.style.display = isLastSubStep ? 'none' : '';
+ }
+
+ // Setup event listeners for current sub-step
+ this.setupSubStepListeners( subStepName, subStepData, state );
+
+ // Show/hide footer based on sub-step
+ this.toggleStepFooter( isLastSubStep );
+
+ // Update Next/Dashboard button state if on last sub-step
+ if ( isLastSubStep ) {
+ this.updateNextButton();
+ }
+ }
+
+ /**
+ * Setup event listeners for a sub-step
+ * @param {string} subStepName - Name of sub-step (about/contact/faq/post-types)
+ * @param {Object} subStepData - Data for this sub-step
+ * @param {Object} state - Wizard state
+ */
+ setupSubStepListeners( subStepName, subStepData, state ) {
+ // Handle page selection sub-steps (about, contact, faq)
+ if (
+ [ 'homepage', 'about', 'contact', 'faq' ].includes( subStepName )
+ ) {
+ this.setupPageSelectListeners( subStepName, subStepData, state );
+ return;
+ }
+
+ // Handle post types sub-step
+ if ( subStepName === 'post-types' ) {
+ this.setupPostTypesListeners( subStepName, subStepData, state );
+ }
+ }
+
+ /**
+ * Setup event listeners for page select sub-steps (about, contact, faq)
+ * @param {string} subStepName - Name of sub-step
+ * @param {Object} subStepData - Data for this sub-step
+ * @param {Object} state - Wizard state
+ */
+ setupPageSelectListeners( subStepName, subStepData, state ) {
+ // Get select and checkbox
+ const pageSelect = this.popover.querySelector(
+ `select[name="pages[${ subStepName }][id]"]`
+ );
+ const noPageCheckbox = this.popover.querySelector(
+ `#prpl-no-${ subStepName }-page`
+ );
+
+ // Get save button
+ const saveButton = this.popover.querySelector(
+ `#prpl-save-${ subStepName }-setting`
+ );
+
+ if ( ! pageSelect || ! noPageCheckbox || ! saveButton ) {
+ return;
+ }
+
+ // Get select wrapper
+ const selectWrapper = this.popover.querySelector(
+ `.prpl-setting-item[data-page="${ subStepName }"] .prpl-select-page`
+ );
+
+ // Set initial states from saved data
+ if ( subStepData.pageId ) {
+ pageSelect.value = subStepData.pageId;
+ }
+
+ if ( ! subStepData.hasPage ) {
+ noPageCheckbox.checked = true;
+ if ( selectWrapper ) {
+ selectWrapper.classList.add( 'prpl-disabled' );
+ }
+ }
+
+ // Page select handler
+ pageSelect.addEventListener( 'change', ( e ) => {
+ subStepData.pageId = e.target.value;
+ this.updateSaveButtonState( saveButton, subStepData );
+
+ // Update Next/Dashboard button if on last sub-step
+ if ( this.currentSubStep === this.subSteps.length - 1 ) {
+ this.updateNextButton();
+ }
+ } );
+
+ // Checkbox handler
+ noPageCheckbox.addEventListener( 'change', ( e ) => {
+ subStepData.hasPage = ! e.target.checked;
+
+ // Display the note if the checkbox is checked.
+ const note = this.popover.querySelector(
+ `.prpl-setting-item[data-page="${ subStepName }"] .prpl-setting-footer .prpl-setting-note`
+ );
+
+ // Hide/show select based on checkbox
+ if ( e.target.checked ) {
+ // Checkbox is checked - hide select
+ if ( selectWrapper ) {
+ selectWrapper.classList.add( 'prpl-disabled' );
+ }
+ pageSelect.value = ''; // Reset selection
+ subStepData.pageId = null;
+ if ( note ) {
+ note.style.display = 'flex';
+ }
+ } else if ( selectWrapper ) {
+ // Checkbox is unchecked - show select
+ if ( selectWrapper ) {
+ selectWrapper.classList.remove( 'prpl-disabled' );
+ }
+
+ if ( note ) {
+ note.style.display = 'none';
+ }
+ }
+
+ this.updateSaveButtonState( saveButton, subStepData );
+
+ // Update Next/Dashboard button if on last sub-step
+ if ( this.currentSubStep === this.subSteps.length - 1 ) {
+ this.updateNextButton();
+ }
+ } );
+
+ // Save button handler - just advances to next sub-step
+ saveButton.addEventListener( 'click', () => {
+ this.advanceSubStep( state );
+ } );
+
+ // Initial button state
+ this.updateSaveButtonState( saveButton, subStepData );
+ }
+
+ /**
+ * Setup event listeners for post types sub-step
+ * @param {string} subStepName - Name of sub-step
+ * @param {Object} subStepData - Data for this sub-step
+ * @param {Object} state - Wizard state
+ */
+ setupPostTypesListeners( subStepName, subStepData, state ) {
+ const container = this.popover.querySelector(
+ `.prpl-setting-item[data-page="${ subStepName }"]`
+ );
+ const saveButton = this.popover.querySelector(
+ `#prpl-save-${ subStepName }-setting`
+ );
+
+ if ( ! container || ! saveButton ) {
+ return;
+ }
+
+ // Get all checkboxes
+ const checkboxes = container.querySelectorAll(
+ 'input[type="checkbox"][name="prpl-post-types-include[]"]'
+ );
+
+ // Initialize selected types from checkboxes that are already checked (from template)
+ // or from saved data if available
+ if (
+ subStepData.selectedTypes &&
+ subStepData.selectedTypes.length > 0
+ ) {
+ // Use saved data if available
+ checkboxes.forEach( ( checkbox ) => {
+ checkbox.checked = subStepData.selectedTypes.includes(
+ checkbox.value
+ );
+ } );
+ } else {
+ // Initialize from checkboxes that are already checked in the template
+ subStepData.selectedTypes = Array.from( checkboxes )
+ .filter( ( cb ) => cb.checked )
+ .map( ( cb ) => cb.value );
+
+ // If no checkboxes are checked, default to all checked
+ if ( subStepData.selectedTypes.length === 0 ) {
+ checkboxes.forEach( ( checkbox ) => {
+ checkbox.checked = true;
+ subStepData.selectedTypes.push( checkbox.value );
+ } );
+ }
+ }
+
+ // Add change listeners to checkboxes
+ checkboxes.forEach( ( checkbox ) => {
+ checkbox.addEventListener( 'change', () => {
+ // Update selected types array
+ subStepData.selectedTypes = Array.from( checkboxes )
+ .filter( ( cb ) => cb.checked )
+ .map( ( cb ) => cb.value );
+
+ this.updateSaveButtonState( saveButton, subStepData );
+
+ // Update Next/Dashboard button if on last sub-step
+ if ( this.currentSubStep === this.subSteps.length - 1 ) {
+ this.updateNextButton();
+ }
+ } );
+ } );
+
+ // Save button handler - just advances to next sub-step
+ saveButton.addEventListener( 'click', () => {
+ this.advanceSubStep( state );
+ } );
+
+ // Initial button state
+ this.updateSaveButtonState( saveButton, subStepData );
+ }
+
+ /**
+ * Advance to next sub-step
+ * @param {Object} state - Wizard state
+ */
+ advanceSubStep( state ) {
+ if ( this.currentSubStep < this.subSteps.length - 1 ) {
+ this.currentSubStep++;
+ this.renderSubStep( state );
+ // Footer visibility is handled in renderSubStep()
+ }
+ }
+
+ /**
+ * Update save button state
+ * @param {HTMLElement} button - Save button element
+ * @param {Object} subStepData - Sub-step data
+ */
+ updateSaveButtonState( button, subStepData ) {
+ const canSave = this.canSaveSubStep( subStepData );
+ button.disabled = ! canSave;
+ }
+
+ /**
+ * Check if sub-step can be saved
+ * @param {Object} subStepData - Sub-step data
+ * @return {boolean} True if can save
+ */
+ canSaveSubStep( subStepData ) {
+ // Handle page selection sub-steps (about, contact, faq)
+ if ( subStepData.hasPage !== undefined ) {
+ // If user has the page, they must select one
+ if ( subStepData.hasPage && ! subStepData.pageId ) {
+ return false;
+ }
+
+ // If checkbox is checked (don't have page), can save
+ if ( ! subStepData.hasPage ) {
+ return true;
+ }
+
+ // If page is selected, can save
+ return !! subStepData.pageId;
+ }
+
+ // Handle post types sub-step - at least one must be selected
+ if ( subStepData.selectedTypes !== undefined ) {
+ return subStepData.selectedTypes.length > 0;
+ }
+
+ return false;
+ }
+
+ /**
+ * User can proceed if on last sub-step and it's valid
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if can proceed
+ */
+ canProceed( state ) {
+ if ( ! state.data.settings ) {
+ return false;
+ }
+
+ // Can only proceed if on last sub-step
+ if ( this.currentSubStep !== this.subSteps.length - 1 ) {
+ return false;
+ }
+
+ // Check if all sub-steps have valid data
+ return this.subSteps.every( ( step ) => {
+ const subStepData = state.data.settings[ step ];
+ return this.canSaveSubStep( subStepData );
+ } );
+ }
+
+ /**
+ * Called before advancing to next step
+ * Saves all settings via AJAX
+ * @return {Promise} Resolves when settings are saved
+ */
+ async beforeNextStep() {
+ const state = this.getState();
+
+ // Show spinner on Next button
+ const spinner = this.showSpinner( this.nextBtn );
+
+ try {
+ // Collect all settings data for a single AJAX request
+ const formDataObj = new FormData();
+ formDataObj.append( 'action', 'prpl_save_all_onboarding_settings' );
+ formDataObj.append(
+ 'nonce',
+ ProgressPlannerOnboardData.nonceProgressPlanner
+ );
+
+ // Collect page settings (about, contact, faq)
+ const pages = {};
+ for ( const subStepName of this.subSteps ) {
+ const subStepData = state.data.settings[ subStepName ];
+
+ if (
+ [ 'homepage', 'about', 'contact', 'faq' ].includes(
+ subStepName
+ )
+ ) {
+ pages[ subStepName ] = {
+ id: subStepData.pageId || '',
+ have_page: subStepData.hasPage ? 'yes' : 'no',
+ };
+ }
+ }
+
+ // Add pages data as JSON
+ if ( Object.keys( pages ).length > 0 ) {
+ formDataObj.append( 'pages', JSON.stringify( pages ) );
+ }
+
+ // Add post types
+ const postTypesData = state.data.settings[ 'post-types' ];
+ if ( postTypesData && postTypesData.selectedTypes ) {
+ postTypesData.selectedTypes.forEach( ( postType ) => {
+ formDataObj.append( 'prpl-post-types-include[]', postType );
+ } );
+ }
+
+ // Send single AJAX request
+ const response = await fetch(
+ ProgressPlannerOnboardData.adminAjaxUrl,
+ {
+ method: 'POST',
+ body: formDataObj,
+ credentials: 'same-origin',
+ }
+ );
+
+ if ( ! response.ok ) {
+ throw new Error( 'Request failed: ' + response.status );
+ }
+
+ const result = await response.json();
+
+ if ( ! result.success ) {
+ throw new Error(
+ result.data?.message || 'Failed to save settings'
+ );
+ }
+
+ console.log( 'Successfully saved all onboarding settings' );
+ } catch ( error ) {
+ console.error( 'Failed to save settings:', error );
+
+ // Display error message
+ this.showErrorMessage(
+ error.message || 'Failed to save settings. Please try again.',
+ 'Error saving setting'
+ );
+
+ // Re-enable button
+ this.setNextButtonDisabled( false );
+
+ // Don't proceed to next step
+ throw error;
+ } finally {
+ spinner.remove();
+ }
+ }
+}
+
+window.PrplSettingsStep = new PrplSettingsStep();
diff --git a/assets/js/onboarding/steps/WelcomeStep.js b/assets/js/onboarding/steps/WelcomeStep.js
new file mode 100644
index 0000000000..8ad4498be3
--- /dev/null
+++ b/assets/js/onboarding/steps/WelcomeStep.js
@@ -0,0 +1,165 @@
+/**
+ * Welcome step - First step in the onboarding flow
+ * Displays a welcome message, logo, and privacy policy checkbox
+ */
+/* global OnboardingStep, LicenseGenerator, ProgressPlannerOnboardData */
+
+class PrplWelcomeStep extends OnboardingStep {
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-welcome',
+ } );
+ this.isGeneratingLicense = false;
+ }
+
+ /**
+ * Mount the welcome step
+ * Sets up checkbox listener and initializes state
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ const checkbox = this.popover.querySelector( '#prpl-privacy-checkbox' );
+
+ if ( ! checkbox ) {
+ return () => {};
+ }
+
+ // Initialize state from checkbox if not already set in saved state
+ if ( state.data.privacyAccepted === undefined ) {
+ state.data.privacyAccepted = checkbox.checked;
+ } else {
+ // Set checkbox state from wizard state
+ checkbox.checked = state.data.privacyAccepted;
+ }
+
+ const handler = ( e ) => {
+ state.data.privacyAccepted = e.target.checked;
+
+ // Remove active class from required indicator.
+ this.popover
+ .querySelector(
+ '.prpl-privacy-checkbox-wrapper .prpl-required-indicator'
+ )
+ .classList.remove( 'prpl-required-indicator-active' );
+ };
+
+ checkbox.addEventListener( 'change', handler );
+
+ return () => {
+ checkbox.removeEventListener( 'change', handler );
+ };
+ }
+
+ /**
+ * Setup custom handler for disabled button clicks
+ * Shows error message when user tries to proceed without accepting privacy policy
+ * @return {Function} Cleanup function
+ */
+ onNextButtonSetup() {
+ const disabledClickHandler = ( e ) => {
+ if ( this.nextBtn.classList.contains( 'prpl-btn-disabled' ) ) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.popover
+ .querySelector(
+ '.prpl-privacy-checkbox-wrapper .prpl-required-indicator'
+ )
+ .classList.add( 'prpl-required-indicator-active' );
+ }
+ };
+
+ this.nextBtn.addEventListener( 'click', disabledClickHandler );
+
+ // Return cleanup function
+ return () => {
+ this.nextBtn?.removeEventListener( 'click', disabledClickHandler );
+ };
+ }
+
+ /**
+ * User can only proceed if privacy policy is accepted
+ * Sites with existing license bypass this check (no privacy checkbox shown).
+ * @param {Object} state - Wizard state
+ * @return {boolean} True if privacy is accepted or license exists
+ */
+ canProceed( state ) {
+ // Sites with license already skip the privacy checkbox.
+ if ( ProgressPlannerOnboardData.hasLicense ) {
+ return true;
+ }
+ return !! state.data.privacyAccepted;
+ }
+
+ /**
+ * Called before advancing to next step
+ * Generates license and shows spinner
+ * Branded sites with existing license skip this step.
+ * @return {Promise} Resolves when license is generated
+ */
+ async beforeNextStep() {
+ // Skip license generation if site already has a license (branded sites).
+ if ( ProgressPlannerOnboardData.hasLicense ) {
+ return;
+ }
+
+ if ( this.isGeneratingLicense ) {
+ return;
+ }
+
+ this.isGeneratingLicense = true;
+
+ // Clear any existing error messages
+ this.clearErrorMessage();
+
+ // Show spinner
+ const spinner = this.showSpinner( this.nextBtn );
+
+ try {
+ // Generate license
+ await this.generateLicense();
+ } catch ( error ) {
+ console.error( 'Failed to generate license:', error );
+
+ // Display error message to user
+ this.showErrorMessage( error.message, 'Error generating license' );
+
+ // Re-enable the button so user can retry
+ this.setNextButtonDisabled( false );
+
+ // Don't proceed to next step
+ throw error;
+ } finally {
+ // Remove spinner
+ spinner.remove();
+ this.isGeneratingLicense = false;
+ }
+ }
+
+ /**
+ * Generate license on server
+ * Uses LicenseGenerator utility class
+ * @return {Promise} Resolves when license is generated
+ */
+ async generateLicense() {
+ // Use LicenseGenerator to handle the license generation process
+ return LicenseGenerator.generateLicense(
+ {
+ name: '',
+ email: '',
+ 'with-email': 'no',
+ site: ProgressPlannerOnboardData.site,
+ timezone_offset: ProgressPlannerOnboardData.timezone_offset,
+ },
+ {
+ onboardNonceURL: ProgressPlannerOnboardData.onboardNonceURL,
+ onboardAPIUrl: ProgressPlannerOnboardData.onboardAPIUrl,
+ adminAjaxUrl: ProgressPlannerOnboardData.adminAjaxUrl,
+ nonce: ProgressPlannerOnboardData.nonceProgressPlanner,
+ }
+ );
+ }
+}
+
+window.PrplWelcomeStep = new PrplWelcomeStep();
diff --git a/assets/js/onboarding/steps/WhatsWhatStep.js b/assets/js/onboarding/steps/WhatsWhatStep.js
new file mode 100644
index 0000000000..c46a49c90a
--- /dev/null
+++ b/assets/js/onboarding/steps/WhatsWhatStep.js
@@ -0,0 +1,34 @@
+/**
+ * Whats What step - Explains the badge system to users
+ * Simple informational step with no user interaction required
+ */
+/* global OnboardingStep */
+class PrplWhatsWhatStep extends OnboardingStep {
+ constructor() {
+ super( {
+ templateId: 'onboarding-step-whats-what',
+ } );
+ }
+
+ /**
+ * No special mounting logic needed for badges step
+ * @param {Object} state - Wizard state
+ * @return {Function} Cleanup function
+ */
+ onMount( state ) {
+ // Whats Next step is informational only
+ // No special logic needed
+ return () => {};
+ }
+
+ /**
+ * User can always proceed from badges step
+ * @param {Object} state - Wizard state
+ * @return {boolean} Always returns true
+ */
+ canProceed( state ) {
+ return true;
+ }
+}
+
+window.PrplWhatsWhatStep = new PrplWhatsWhatStep();
diff --git a/assets/js/recommendations/set-page.js b/assets/js/recommendations/set-page.js
new file mode 100644
index 0000000000..fbca6755be
--- /dev/null
+++ b/assets/js/recommendations/set-page.js
@@ -0,0 +1,140 @@
+/* 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 = 'hidden';
+ }
+
+ // Show only select and edit button.
+ if ( 'yes' === value ) {
+ // Show 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
new file mode 100644
index 0000000000..218133b48e
--- /dev/null
+++ b/assets/js/recommendations/set-valuable-post-types.js
@@ -0,0 +1,49 @@
+/* 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/settings-page.js b/assets/js/settings-page.js
deleted file mode 100644
index d7a8134866..0000000000
--- a/assets/js/settings-page.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/* global alert, prplDocumentReady */
-/*
- * Settings Page
- *
- * A script to handle the settings page.
- *
- * Dependencies: progress-planner/document-ready, wp-util
- */
-const prplTogglePageSelectorSettingVisibility = function ( page, value ) {
- const itemRadiosWrapperEl = document.querySelector(
- `.prpl-pages-item-${ page } .radios`
- );
-
- if ( ! itemRadiosWrapperEl ) {
- return;
- }
-
- // Show only create button.
- if ( 'no' === value || 'not-applicable' === value ) {
- // Hide wrapper.
- itemRadiosWrapperEl.querySelector(
- '.prpl-select-page'
- ).style.visibility = 'hidden';
- }
-
- // Show only select and edit button.
- if ( 'yes' === value ) {
- // Show wrapper.
- itemRadiosWrapperEl.querySelector(
- '.prpl-select-page'
- ).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 );
- } );
- }
- } );
-} );
-
-/**
- * Handle the form submission.
- */
-prplDocumentReady( function () {
- const prplFormSubmit = function ( event ) {
- event.preventDefault();
- const formData = new FormData(
- document.getElementById( 'prpl-settings' )
- );
- const data = {
- action: 'prpl_settings_form',
- };
- formData.forEach( function ( value, key ) {
- // Handle array notation in keys
- if ( key.endsWith( '[]' ) ) {
- const baseKey = key.slice( 0, -2 );
- if ( ! data[ baseKey ] ) {
- data[ baseKey ] = [];
- }
- data[ baseKey ].push( value );
- } else {
- data[ key ] = value;
- }
- } );
- const request = wp.ajax.post( 'prpl_settings_form', data );
- request.done( function () {
- window.location.reload();
- } );
- request.fail( function ( response ) {
- alert( response.licensingError || response ); // eslint-disable-line no-alert
- } );
- };
- document
- .getElementById( 'prpl-settings-submit' )
- .addEventListener( 'click', prplFormSubmit );
- document
- .getElementById( 'prpl-settings' )
- .addEventListener( 'submit', prplFormSubmit );
-} );
diff --git a/assets/js/settings.js b/assets/js/settings.js
index 6695710039..32d7670391 100644
--- a/assets/js/settings.js
+++ b/assets/js/settings.js
@@ -1,10 +1,10 @@
-/* global progressPlanner, progressPlannerAjaxRequest, progressPlannerSaveLicenseKey, prplL10n */
+/* global prplL10n, LicenseGenerator */
/*
* Settings
*
* A script to handle the settings page.
*
- * Dependencies: progress-planner/ajax-request, progress-planner/onboard, wp-util, progress-planner/l10n
+ * Dependencies: progress-planner/l10n, progress-planner/license-generator
*/
// Submit the email.
@@ -22,47 +22,23 @@ if ( !! settingsLicenseForm ) {
data[ key ] = value;
}
- progressPlannerAjaxRequest( {
- url: progressPlanner.onboardNonceURL,
- data,
- } )
- .then( ( response ) => {
- if ( 'ok' === response.status ) {
- // Add the nonce to our data object.
- data.nonce = response.nonce;
-
- // Make the request to the API.
- progressPlannerAjaxRequest( {
- url: progressPlanner.onboardAPIUrl,
- data,
- } )
- .then( ( apiResponse ) => {
- // Make a local request to save the response data.
- progressPlannerSaveLicenseKey(
- apiResponse.license_key
- );
+ document.getElementById( 'submit-license-key' ).disabled = true;
+ document.getElementById( 'submit-license-key' ).innerHTML =
+ prplL10n( 'subscribing' );
- document.getElementById(
- 'submit-license-key'
- ).innerHTML = prplL10n( 'subscribed' );
+ LicenseGenerator.generateLicense( data )
+ .then( () => {
+ document.getElementById( 'submit-license-key' ).innerHTML =
+ prplL10n( 'subscribed' );
- // Timeout so the license key is saved.
- setTimeout( () => {
- // Reload the page.
- window.location.reload();
- }, 500 );
- } )
- .catch( ( error ) => {
- console.warn( error );
- } );
- }
+ // Timeout so the license key is saved.
+ setTimeout( () => {
+ // Reload the page.
+ window.location.reload();
+ }, 500 );
} )
.catch( ( error ) => {
console.warn( error );
} );
-
- document.getElementById( 'submit-license-key' ).disabled = true;
- document.getElementById( 'submit-license-key' ).innerHTML =
- prplL10n( 'subscribing' );
} );
}
diff --git a/assets/js/widgets/suggested-tasks.js b/assets/js/widgets/suggested-tasks.js
index b515b2b607..102529bdea 100644
--- a/assets/js/widgets/suggested-tasks.js
+++ b/assets/js/widgets/suggested-tasks.js
@@ -4,7 +4,7 @@
*
* 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/grid-masonry, progress-planner/web-components/prpl-tooltip, progress-planner/suggested-task-terms
+ * 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 */
diff --git a/assets/js/widgets/todo.js b/assets/js/widgets/todo.js
index f0be0c52f7..64d55a433c 100644
--- a/assets/js/widgets/todo.js
+++ b/assets/js/widgets/todo.js
@@ -4,7 +4,7 @@
*
* A widget that displays a todo list.
*
- * Dependencies: wp-api, progress-planner/suggested-task, wp-util, wp-a11y, progress-planner/grid-masonry, progress-planner/celebrate, progress-planner/suggested-task-terms, progress-planner/l10n
+ * Dependencies: wp-api, progress-planner/suggested-task, wp-util, wp-a11y, progress-planner/celebrate, progress-planner/suggested-task-terms, progress-planner/l10n
*/
const prplTodoWidget = {
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
new file mode 100755
index 0000000000..f96bf9ef06
--- /dev/null
+++ b/bin/install-wp-tests.sh
@@ -0,0 +1,169 @@
+#!/usr/bin/env bash
+
+if [ $# -lt 3 ]; then
+ echo "usage: $0 [db-host] [wp-version] [skip-database-creation]"
+ exit 1
+fi
+
+DB_NAME=$1
+DB_USER=$2
+DB_PASS=$3
+DB_HOST=${4-localhost}
+WP_VERSION=${5-latest}
+SKIP_DB_CREATE=${6-false}
+
+TMPDIR=${TMPDIR-/tmp}
+TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
+WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
+WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/}
+
+download() {
+ if [ `which curl` ]; then
+ curl -s "$1" > "$2";
+ elif [ `which wget` ]; then
+ wget -nv -O "$2" "$1"
+ fi
+}
+
+if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then
+ WP_BRANCH=${WP_VERSION%\-*}
+ WP_TESTS_TAG="branches/$WP_BRANCH"
+
+elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
+ WP_TESTS_TAG="branches/$WP_VERSION"
+elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
+ # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
+ WP_TESTS_TAG="tags/${WP_VERSION%??}"
+ else
+ WP_TESTS_TAG="tags/$WP_VERSION"
+ fi
+elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
+ WP_TESTS_TAG="trunk"
+else
+ # http serves a single offer, whereas https serves multiple. we only want one
+ download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
+ grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
+ LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
+ if [[ -z "$LATEST_VERSION" ]]; then
+ echo "Latest WordPress version could not be found"
+ exit 1
+ fi
+ WP_TESTS_TAG="tags/$LATEST_VERSION"
+fi
+set -ex
+
+install_wp() {
+
+ if [ -d $WP_CORE_DIR ]; then
+ return;
+ fi
+
+ mkdir -p $WP_CORE_DIR
+
+ if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
+ mkdir -p $TMPDIR/wordpress-trunk
+ rm -rf $TMPDIR/wordpress-trunk/*
+ svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress
+ mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR
+ else
+ if [ $WP_VERSION == 'latest' ]; then
+ local ARCHIVE_NAME='latest'
+ elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
+ # https serves multiple offers, whereas http serves single.
+ download https://wordpress.org/wordpress-$WP_VERSION.tar.gz $TMPDIR/wordpress.tar.gz
+ ARCHIVE_NAME="wordpress-$WP_VERSION"
+ fi
+
+ if [ ! -f $TMPDIR/wordpress.tar.gz ]; then
+ download https://wordpress.org/latest.tar.gz $TMPDIR/wordpress.tar.gz
+ fi
+ tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
+ fi
+
+ download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
+}
+
+install_test_suite() {
+ # portable in-place argument for both GNU sed and Mac OSX sed
+ if [[ $(uname -s) == 'Darwin' ]]; then
+ local ioption='-i.bak'
+ else
+ local ioption='-i'
+ fi
+
+ # set up testing suite if it doesn't yet exist
+ if [ ! -d $WP_TESTS_DIR ]; then
+ # set up testing suite
+ mkdir -p $WP_TESTS_DIR
+ rm -rf $WP_TESTS_DIR/{includes,data}
+ svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
+ svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
+ fi
+
+ if [ ! -f wp-tests-config.php ]; then
+ download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
+ # remove all forward slashes in the end
+ WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
+ sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
+ sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
+ fi
+
+}
+
+recreate_db() {
+ shopt -s nocasematch
+ if [[ $1 =~ ^(y|yes)$ ]]
+ then
+ mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA
+ create_db
+ echo "Recreated the database ($DB_NAME)."
+ else
+ echo "Leaving the existing database ($DB_NAME) in place."
+ fi
+ shopt -u nocasematch
+}
+
+create_db() {
+ mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
+}
+
+install_db() {
+
+ if [ ${SKIP_DB_CREATE} = "true" ]; then
+ return 0
+ fi
+
+ # parse DB_HOST for port or socket references
+ local PARTS=(${DB_HOST//\:/ })
+ local DB_HOSTNAME=${PARTS[0]};
+ local DB_SOCK_OR_PORT=${PARTS[1]};
+ local EXTRA=""
+
+ if ! [ -z $DB_HOSTNAME ] ; then
+ if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
+ EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
+ elif ! [ -z $DB_SOCK_OR_PORT ] ; then
+ EXTRA=" --socket=$DB_SOCK_OR_PORT"
+ elif ! [ -z $DB_HOSTNAME ] ; then
+ EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
+ fi
+ fi
+
+ # create database
+ if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ]
+ then
+ echo "Reinstalling will delete the existing test database ($DB_NAME)"
+ read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB
+ recreate_db $DELETE_EXISTING_DB
+ else
+ create_db
+ fi
+}
+
+install_wp
+install_test_suite
+install_db
diff --git a/classes/actions/class-content.php b/classes/actions/class-content.php
index 27df621dc1..6a0c3ec93f 100644
--- a/classes/actions/class-content.php
+++ b/classes/actions/class-content.php
@@ -187,10 +187,23 @@ private function should_skip_saving( $post ) {
/**
* Check if there is a recent activity for this post.
*
+ * Prevents duplicate activity records by checking if a similar activity was already recorded.
+ * Different activity types use different timeframes:
+ *
+ * Update activities (Β±12 hours):
+ * - Uses a 24-hour window (Β±12 hours from modification time) to group related updates
+ * - Prevents multiple update records when a post is saved repeatedly during editing
+ * - Example: Editing a post at 3 PM won't create new activities if one exists between 3 AM and 3 AM next day
+ * - The window accounts for timezone differences and allows one update record per day
+ *
+ * Other activities (exact match):
+ * - Publish, trash, delete, etc. check for exact type/post matches
+ * - No date window needed since these are discrete, one-time events
+ *
* @param \WP_Post $post The post object.
* @param string $type The type of activity (ie publish, update, trash, delete etc).
*
- * @return bool
+ * @return bool True if a recent activity exists (skip recording), false otherwise (record new activity).
*/
private function is_there_recent_activity( $post, $type ) {
// Query arguments.
@@ -200,7 +213,9 @@ private function is_there_recent_activity( $post, $type ) {
'data_id' => (string) $post->ID,
];
- // If it's an update add the start and end date. We don't want to add multiple update activities for the same post on the same day.
+ // For updates, use a Β±12 hour window to prevent duplicate update records during editing sessions.
+ // This groups all updates within a 24-hour period into a single activity.
+ // Other activity types (publish, trash, delete) don't need a window since they're one-time events.
if ( 'update' === $type ) {
$query_args['start_date'] = \progress_planner()->get_utils__date()->get_datetime_from_mysql_date( $post->post_modified )->modify( '-12 hours' );
$query_args['end_date'] = \progress_planner()->get_utils__date()->get_datetime_from_mysql_date( $post->post_modified )->modify( '+12 hours' );
diff --git a/classes/actions/class-maintenance.php b/classes/actions/class-maintenance.php
index 9519179ab4..46157f4819 100644
--- a/classes/actions/class-maintenance.php
+++ b/classes/actions/class-maintenance.php
@@ -134,22 +134,50 @@ protected function create_maintenance_activity( $type ) {
}
/**
- * Get the type of the update.
+ * Get the type of the update from WordPress upgrade_* action options.
*
- * @param array $options The options.
+ * WordPress passes different type values depending on what was updated:
+ * - 'plugin': Single plugin update via upgrader_process_complete
+ * - 'theme': Single theme update
+ * - 'core': WordPress core update
+ * - 'translation': Language pack update
*
- * @return string
+ * Returns 'unknown' when:
+ * - The type field is missing from options (shouldn't happen in normal operation)
+ * - Hook is called incorrectly without proper options
+ *
+ * @param array $options {
+ * Options array from WordPress upgrader_process_complete action.
+ *
+ * @type string $type The type of update: 'plugin', 'theme', 'core', 'translation'.
+ * @type string $action The action performed: 'update', 'install'.
+ * }
+ *
+ * @return string The update type ('plugin', 'theme', 'core', 'translation', or 'unknown').
*/
protected function get_update_type( $options ) {
return isset( $options['type'] ) ? $options['type'] : 'unknown';
}
/**
- * Get the type of the install.
+ * Get the type of the install from WordPress install action options.
+ *
+ * WordPress passes different type values depending on what was installed:
+ * - 'plugin': New plugin installation
+ * - 'theme': New theme installation
+ *
+ * Returns 'unknown' when:
+ * - The type field is missing from options
+ * - Installation fails or is interrupted
+ *
+ * @param array $options {
+ * Options array from WordPress upgrader_process_complete action.
*
- * @param array $options The options.
+ * @type string $type The type of installation: 'plugin' or 'theme'.
+ * @type string $action The action performed: 'install'.
+ * }
*
- * @return string
+ * @return string The install type ('plugin', 'theme', or 'unknown').
*/
protected function get_install_type( $options ) {
return isset( $options['type'] ) ? $options['type'] : 'unknown';
diff --git a/classes/activities/class-query.php b/classes/activities/class-query.php
index e69e1a6804..4b4e1f9c23 100644
--- a/classes/activities/class-query.php
+++ b/classes/activities/class-query.php
@@ -181,17 +181,32 @@ public function query_activities_get_raw( $args ) {
return [];
}
- // Remove duplicates. This could be removed in a future release.
+ // Remove duplicate activities and clean up the database.
+ // Duplicates can occur due to race conditions in concurrent processes.
+ // This cleanup routine identifies duplicates by creating a unique key from:
+ // - category (e.g., 'content', 'maintenance')
+ // - type (e.g., 'post_publish', 'plugin_update')
+ // - data_id (e.g., post ID, plugin slug)
+ // - date (Y-m-d format)
+ // When duplicates are found, only the first occurrence is kept, and subsequent
+ // duplicates are permanently deleted from the database.
+ // This could be removed in a future release once all legacy duplicates are cleaned up.
$results_unique = [];
foreach ( $results as $key => $result ) {
+ // Generate unique key for this activity based on its core identifying attributes.
$result_key = $result->category . $result->type . $result->data_id . $result->date; // @phpstan-ignore-line property.nonObject
- // Cleanup any duplicates that may exist.
+
+ // If we've already seen an activity with this key, it's a duplicate - delete it.
if ( isset( $results_unique[ $result_key ] ) ) {
$this->delete_activity_by_id( $result->id ); // @phpstan-ignore-line property.nonObject
continue;
}
- $results_unique[ $result->category . $result->type . $result->data_id . $result->date ] = $result; // @phpstan-ignore-line property.nonObject
+
+ // First occurrence of this activity - keep it.
+ $results_unique[ $result_key ] = $result;
}
+
+ // Return array values to reset numeric keys (0, 1, 2...) after filtering.
return \array_values( $results_unique );
}
diff --git a/classes/admin/class-enqueue.php b/classes/admin/class-enqueue.php
index 9b48aeff19..e2635bca98 100644
--- a/classes/admin/class-enqueue.php
+++ b/classes/admin/class-enqueue.php
@@ -425,8 +425,8 @@ public function maybe_empty_session_storage() {
return;
}
- // Inject the script only on the Progress Planner Dashboard, Progress Planner Settings and the WordPress dashboard pages.
- if ( 'toplevel_page_progress-planner' !== $screen->id && 'progress-planner_page_progress-planner-settings' !== $screen->id && 'dashboard' !== $screen->id ) {
+ // Inject the script only on the Progress Planner Dashboard and the WordPress dashboard pages.
+ if ( 'toplevel_page_progress-planner' !== $screen->id && 'dashboard' !== $screen->id ) {
return;
}
?>
diff --git a/classes/admin/class-page-settings.php b/classes/admin/class-page-settings.php
index 7d7d42c8f0..ba5e396897 100644
--- a/classes/admin/class-page-settings.php
+++ b/classes/admin/class-page-settings.php
@@ -14,42 +14,6 @@
*/
class Page_Settings {
- /**
- * Constructor.
- */
- public function __construct() {
- // Add the admin menu page.
- \add_action( 'admin_menu', [ $this, 'add_admin_menu_page' ] );
-
- // Add AJAX hooks to save options.
- \add_action( 'wp_ajax_prpl_settings_form', [ $this, 'store_settings_form_options' ] );
- }
-
- /**
- * Add admin-menu page, as a submenu in the progress-planner menu.
- *
- * @return void
- */
- public function add_admin_menu_page() {
- \add_submenu_page(
- 'progress-planner',
- \esc_html__( 'Settings', 'progress-planner' ),
- \esc_html__( 'Settings', 'progress-planner' ),
- 'manage_options',
- 'progress-planner-settings',
- [ $this, 'add_admin_page_content' ]
- );
- }
-
- /**
- * Add content to the admin page of the free plugin.
- *
- * @return void
- */
- public function add_admin_page_content() {
- require_once PROGRESS_PLANNER_DIR . '/views/admin-page-settings.php';
- }
-
/**
* Get an array of settings.
*
@@ -58,27 +22,28 @@ public function add_admin_page_content() {
public function get_settings() {
$settings = [];
foreach ( \progress_planner()->get_page_types()->get_page_types() as $page_type ) {
- if ( ! $this->should_show_setting( $page_type['slug'] ) ) {
+ $slug = (string) $page_type['slug']; // @phpstan-ignore offsetAccess.invalidOffset
+ if ( ! $this->should_show_setting( $slug ) ) {
continue;
}
- $settings[ $page_type['slug'] ] = [
- 'id' => $page_type['slug'],
+ $settings[ $slug ] = [
+ 'id' => $slug,
'value' => '_no_page_needed',
'isset' => 'no',
- 'title' => $page_type['title'],
- 'description' => $page_type['description'] ?? '',
+ 'title' => $page_type['title'], // @phpstan-ignore offsetAccess.invalidOffset
+ 'description' => $page_type['description'] ?? '', // @phpstan-ignore offsetAccess.invalidOffset
'type' => 'page-select',
- 'page' => $page_type['slug'],
+ 'page' => $slug,
];
- if ( \progress_planner()->get_page_types()->is_page_needed( $page_type['slug'] ) ) {
- $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $page_type['slug'] );
+ if ( \progress_planner()->get_page_types()->is_page_needed( $slug ) ) {
+ $type_pages = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $slug );
if ( empty( $type_pages ) ) {
- $settings[ $page_type['slug'] ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $page_type['slug'] );
+ $settings[ $slug ]['value'] = \progress_planner()->get_page_types()->get_default_page_id_by_type( $slug );
} else {
- $settings[ $page_type['slug'] ]['value'] = $type_pages[0]->ID;
- $settings[ $page_type['slug'] ]['isset'] = 'yes';
+ $settings[ $slug ]['value'] = $type_pages[0]->ID;
+ $settings[ $slug ]['isset'] = 'yes';
// If there is more than one page, we need to check if the page has a parent with the same page-type assigned.
if ( 1 < \count( $type_pages ) ) {
@@ -89,7 +54,7 @@ public function get_settings() {
foreach ( $type_pages as $type_page ) {
$parent = \get_post_field( 'post_parent', $type_page->ID );
if ( $parent && \in_array( (int) $parent, $type_pages_ids, true ) ) {
- $settings[ $page_type['slug'] ]['value'] = $parent;
+ $settings[ $slug ]['value'] = $parent;
break;
}
}
@@ -123,95 +88,75 @@ public function should_show_setting( $page_type ) {
}
/**
- * Store the settings form options.
+ * Set the page value.
+ *
+ * @param array $pages The pages.
*
* @return void
*/
- public function store_settings_form_options() {
+ public function set_page_values( $pages ) {
- if ( ! \current_user_can( 'manage_options' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] );
+ if ( empty( $pages ) ) {
+ return;
}
- // Use check_ajax_referer instead of check_admin_referer for AJAX handlers.
- // check_admin_referer is designed for form submissions, not AJAX requests.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
-
- if ( isset( $_POST['pages'] ) ) {
- // Sanitize the pages array at point of reception.
- $pages = \map_deep( \wp_unslash( $_POST['pages'] ), 'sanitize_text_field' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- foreach ( $pages as $type => $page_args ) {
- $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';
+ foreach ( $pages as $type => $page_args ) {
+ $need_page = isset( $page_args['have_page'] ) ? $page_args['have_page'] : '';
- \progress_planner()->get_page_types()->set_no_page_needed(
- $type,
- 'not-applicable' === $need_page
- );
-
- // Remove the post-meta from the existing posts.
- $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
- foreach ( $existing_posts as $post ) {
- if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
- continue;
- }
+ \progress_planner()->get_page_types()->set_no_page_needed(
+ $type,
+ 'not-applicable' === $need_page
+ );
- // Get the term-ID for the type.
- $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
- if ( ! $term instanceof \WP_Term ) {
- continue;
- }
-
- // Remove the assigned terms from the `progress_planner_page_types` taxonomy.
- \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
+ // Remove the post-meta from the existing posts.
+ $existing_posts = \progress_planner()->get_page_types()->get_posts_by_type( 'any', $type );
+ foreach ( $existing_posts as $post ) {
+ if ( $post->ID === (int) $page_args['id'] && 'no' !== $page_args['have_page'] ) {
+ continue;
}
- // Skip if the ID is not set.
- if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+ // Get the term-ID for the type.
+ $term = \get_term_by( 'slug', $type, Page_Types::TAXONOMY_NAME );
+ if ( ! $term instanceof \WP_Term ) {
continue;
}
- if ( 'no' !== $page_args['have_page'] ) {
- // Add the term to the `progress_planner_page_types` taxonomy.
- \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
- }
+ // Remove the assigned terms from the `progress_planner_page_types` taxonomy.
+ \wp_remove_object_terms( $post->ID, $term->term_id, Page_Types::TAXONOMY_NAME );
}
- }
-
- $this->save_settings();
- $this->save_post_types();
- \do_action( 'progress_planner_settings_form_options_stored' );
+ // Skip if the ID is not set.
+ if ( ! isset( $page_args['id'] ) || 1 > (int) $page_args['id'] ) {
+ continue;
+ }
- \wp_send_json_success( \esc_html__( 'Options stored successfully', 'progress-planner' ) );
+ if ( 'no' !== $page_args['have_page'] ) {
+ // Add the term to the `progress_planner_page_types` taxonomy.
+ \progress_planner()->get_page_types()->set_page_type_by_id( (int) $page_args['id'], $type );
+ }
+ }
}
/**
* Save the settings.
*
+ * @param bool $redirect_on_login Whether to redirect on login.
* @return void
*/
- public function save_settings() {
- // Nonce is already checked in store_settings_form_options() which calls this method.
- $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- ? \sanitize_text_field( \wp_unslash( $_POST['prpl-redirect-on-login'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- : false;
-
- \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', (bool) $redirect_on_login );
+ public function save_settings( $redirect_on_login ) {
+ \update_user_meta( \get_current_user_id(), 'prpl_redirect_on_login', $redirect_on_login );
}
/**
* Save the post types.
*
+ * @param array $post_types The post types.
+ *
* @return void
*/
- public function save_post_types() {
- // Nonce is already checked in store_settings_form_options() which calls this method.
- $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
- ? \array_map( 'sanitize_text_field', \wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
- // If no post types are selected, use the default post types (post and page can be deregistered).
+ public function save_post_types( $post_types = [] ) {
+ $include_post_types = ! empty( $post_types )
+ ? $post_types
: \array_intersect( [ 'post', 'page' ], \progress_planner()->get_settings()->get_public_post_types() );
\progress_planner()->get_settings()->set( 'include_post_types', $include_post_types );
diff --git a/classes/admin/class-page.php b/classes/admin/class-page.php
index c57eed09f0..f5f36fc302 100644
--- a/classes/admin/class-page.php
+++ b/classes/admin/class-page.php
@@ -157,7 +157,7 @@ public function render_page() {
*/
public function enqueue_assets( $hook ) {
$this->maybe_enqueue_focus_el_script( $hook );
- if ( 'toplevel_page_progress-planner' !== $hook && 'progress-planner_page_progress-planner-settings' !== $hook ) {
+ if ( 'toplevel_page_progress-planner' !== $hook ) {
return;
}
@@ -204,20 +204,6 @@ public function enqueue_scripts() {
\progress_planner()->get_admin__enqueue()->enqueue_script( 'external-link-accessibility-helper' );
}
-
- if ( 'progress-planner_page_progress-planner-settings' === $current_screen->id ) {
- \progress_planner()->get_admin__enqueue()->enqueue_script(
- 'settings-page',
- [
- 'name' => 'progressPlannerSettingsPage',
- 'data' => [
- 'siteUrl' => \get_site_url(),
- ],
- ]
- );
-
- \progress_planner()->get_admin__enqueue()->enqueue_script( 'external-link-accessibility-helper' );
- }
}
/**
@@ -228,20 +214,26 @@ public function enqueue_scripts() {
* @return void
*/
public function maybe_enqueue_focus_el_script( $hook ) {
+ // Get all registered task providers from the task manager.
$tasks_providers = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_providers();
$tasks_details = [];
$total_points = 0;
$completed_points = 0;
+
+ // Filter providers to only those relevant to the current admin page.
foreach ( $tasks_providers as $provider ) {
$link_setting = $provider->get_link_setting();
+
+ // Skip tasks that aren't configured for this admin page.
if ( ! isset( $link_setting['hook'] ) ||
$hook !== $link_setting['hook']
) {
continue;
}
+ // Build task details for JavaScript.
$details = [
- 'link_setting' => $link_setting,
+ 'link_setting' => $link_setting, // Contains selector, hook, and highlight config.
'task_id' => $provider->get_task_id(),
'points' => $provider->get_points(),
'is_complete' => $provider->is_task_completed(),
@@ -254,11 +246,12 @@ public function maybe_enqueue_focus_el_script( $hook ) {
}
}
+ // No tasks for this page - don't enqueue the script.
if ( empty( $tasks_details ) ) {
return;
}
- // Register the scripts.
+ // Enqueue the focus element script with task data.
\progress_planner()->get_admin__enqueue()->enqueue_script(
'focus-element',
[
@@ -298,23 +291,10 @@ public function enqueue_styles() {
\progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/web-components/prpl-tooltip' );
\progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/web-components/prpl-install-plugin' );
- if ( 'progress-planner_page_progress-planner-settings' === $current_screen->id ) {
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/settings-page' );
- }
-
if ( 'toplevel_page_progress-planner' === $current_screen->id ) {
// Enqueue ugprading (onboarding) tasks styles, these are needed both when privacy policy is accepted and when it is not.
\progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/upgrade-tasks' );
}
-
- $prpl_privacy_policy_accepted = \progress_planner()->is_privacy_policy_accepted();
- if ( ! $prpl_privacy_policy_accepted ) {
- // Enqueue welcome styles.
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/welcome' );
-
- // Enqueue onboarding styles.
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/onboard' );
- }
}
/**
@@ -331,7 +311,6 @@ public function remove_admin_notices() {
$current_screen->id,
[
'toplevel_page_progress-planner',
- 'progress-planner_page_progress-planner-settings',
],
true
) ) {
diff --git a/classes/admin/widgets/class-activity-scores.php b/classes/admin/widgets/class-activity-scores.php
index 974c7abba8..8adcde6448 100644
--- a/classes/admin/widgets/class-activity-scores.php
+++ b/classes/admin/widgets/class-activity-scores.php
@@ -98,7 +98,8 @@ public function get_checklist_results() {
$items = $this->get_checklist();
$results = [];
foreach ( $items as $item ) {
- $results[ $item['label'] ] = $item['callback']();
+ $label = (string) $item['label']; // @phpstan-ignore offsetAccess.invalidOffset
+ $results[ $label ] = $item['callback'](); // @phpstan-ignore offsetAccess.invalidOffset
}
return $results;
}
diff --git a/classes/admin/widgets/class-badge-streak-content.php b/classes/admin/widgets/class-badge-streak-content.php
index fe5d1ed10b..eca6d5244a 100644
--- a/classes/admin/widgets/class-badge-streak-content.php
+++ b/classes/admin/widgets/class-badge-streak-content.php
@@ -25,13 +25,4 @@ final class Badge_Streak_Content extends Badge_Streak {
* @var bool
*/
protected $force_last_column = true;
-
- /**
- * Enqueue styles.
- *
- * @return void
- */
- public function enqueue_styles() {
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/page-widgets/badge-streak' );
- }
}
diff --git a/classes/admin/widgets/class-badge-streak-maintenance.php b/classes/admin/widgets/class-badge-streak-maintenance.php
index 31c518a3e8..9954103398 100644
--- a/classes/admin/widgets/class-badge-streak-maintenance.php
+++ b/classes/admin/widgets/class-badge-streak-maintenance.php
@@ -25,13 +25,4 @@ final class Badge_Streak_Maintenance extends Badge_Streak {
* @var bool
*/
protected $force_last_column = true;
-
- /**
- * Enqueue styles.
- *
- * @return void
- */
- public function enqueue_styles() {
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/page-widgets/badge-streak' );
- }
}
diff --git a/classes/admin/widgets/class-badge-streak.php b/classes/admin/widgets/class-badge-streak.php
index 30a64cc346..5ce16aac19 100644
--- a/classes/admin/widgets/class-badge-streak.php
+++ b/classes/admin/widgets/class-badge-streak.php
@@ -19,6 +19,15 @@ abstract class Badge_Streak extends Widget {
*/
protected $id = 'badge-streak';
+ /**
+ * Enqueue styles.
+ *
+ * @return void
+ */
+ public function enqueue_styles() {
+ \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/page-widgets/badge-streak' );
+ }
+
/**
* Get the badge.
*
diff --git a/classes/admin/widgets/class-challenge.php b/classes/admin/widgets/class-challenge.php
index 1d4c8a0219..79fc75e87f 100644
--- a/classes/admin/widgets/class-challenge.php
+++ b/classes/admin/widgets/class-challenge.php
@@ -90,7 +90,7 @@ public function get_cache_key() {
public function get_remote_api_url() {
return \add_query_arg(
[
- 'license_key' => \get_option( 'progress_planner_license_key' ),
+ 'license_key' => \progress_planner()->get_license_key(),
'site' => \get_site_url(),
],
\progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/challenges'
diff --git a/classes/admin/widgets/class-widget.php b/classes/admin/widgets/class-widget.php
index 9dac376c97..53b4c81a33 100644
--- a/classes/admin/widgets/class-widget.php
+++ b/classes/admin/widgets/class-widget.php
@@ -7,6 +7,8 @@
namespace Progress_Planner\Admin\Widgets;
+use Progress_Planner\Utils\Traits\Input_Sanitizer;
+
/**
* Widgets class.
*
@@ -14,6 +16,8 @@
*/
abstract class Widget {
+ use Input_Sanitizer;
+
/**
* The widget width.
*
@@ -58,11 +62,7 @@ public function get_id() {
* @return string
*/
public function get_range() {
- // phpcs:ignore WordPress.Security.NonceVerification
- return isset( $_GET['range'] )
- // phpcs:ignore WordPress.Security.NonceVerification
- ? \sanitize_text_field( \wp_unslash( $_GET['range'] ) )
- : '-6 months';
+ return $this->get_sanitized_get( 'range', '-6 months' );
}
/**
@@ -71,11 +71,7 @@ public function get_range() {
* @return string
*/
public function get_frequency() {
- // phpcs:ignore WordPress.Security.NonceVerification
- return isset( $_GET['frequency'] )
- // phpcs:ignore WordPress.Security.NonceVerification
- ? \sanitize_text_field( \wp_unslash( $_GET['frequency'] ) )
- : 'monthly';
+ return $this->get_sanitized_get( 'frequency', 'monthly' );
}
/**
diff --git a/classes/badges/class-monthly.php b/classes/badges/class-monthly.php
index eb772e7541..449fe35aa9 100644
--- a/classes/badges/class-monthly.php
+++ b/classes/badges/class-monthly.php
@@ -292,9 +292,11 @@ public function get_next_badge_id() {
* @return int
*/
public function get_next_badges_excess_points() {
- $excess_points = 0;
- $next_1_badge_points = 0;
- $next_2_badge_points = 0;
+ $next_1_badge_points = 0;
+ $next_2_badge_points = 0;
+ $badge_1_excess_points = 0;
+ $badge_2_excess_points = 0;
+
// Get the next badge object.
$next_1_badge = self::get_instance_from_id( $this->get_next_badge_id() );
if ( $next_1_badge ) {
@@ -306,9 +308,21 @@ public function get_next_badges_excess_points() {
}
}
- $excess_points = \max( 0, $next_1_badge_points - self::TARGET_POINTS );
- $excess_points += \max( 0, $next_2_badge_points - 2 * self::TARGET_POINTS );
+ // If the $next_1_badge has more than 10 points, calculate the excess points.
+ if ( $next_1_badge_points > self::TARGET_POINTS ) {
+ $badge_1_excess_points = \max( 0, $next_1_badge_points - self::TARGET_POINTS );
+ }
+
+ // If the $next_2_badge has more than 10 points, calculate the excess points.
+ if ( $next_2_badge_points > self::TARGET_POINTS ) {
+ $badge_2_excess_points = \max( 0, $next_2_badge_points - self::TARGET_POINTS );
+
+ // Does the $next_1_badge need more points to reach 10?
+ if ( $next_1_badge_points < self::TARGET_POINTS ) {
+ $badge_2_excess_points = \max( 0, ( $next_1_badge_points + $badge_2_excess_points ) - self::TARGET_POINTS );
+ }
+ }
- return (int) $excess_points;
+ return (int) $badge_1_excess_points + (int) $badge_2_excess_points;
}
}
diff --git a/classes/class-badges.php b/classes/class-badges.php
index 7807bd9bdb..904c94f0b2 100644
--- a/classes/class-badges.php
+++ b/classes/class-badges.php
@@ -156,35 +156,51 @@ public function clear_content_progress() {
}
/**
- * Get the latest completed badge.
+ * Get the latest completed badge across all badge types.
*
- * @return \Progress_Planner\Badges\Badge|null
+ * Badge selection algorithm:
+ * 1. Iterates through all badge contexts (content, maintenance, monthly_flat)
+ * 2. For each badge, checks if it's 100% complete
+ * 3. Compares completion dates stored in settings to find the most recent
+ * 4. Returns the badge with the most recent completion date
+ *
+ * The completion date is stored in settings when a badge reaches 100% progress:
+ * - Format: 'Y-m-d H:i:s' (e.g., '2025-10-31 14:30:00')
+ * - Compared as Unix timestamps for accurate chronological ordering
+ * - Later completion dates take precedence (>= comparison ensures newer badges win)
+ *
+ * This is used to:
+ * - Trigger celebrations for newly completed badges
+ * - Track user progress momentum
+ *
+ * @return \Progress_Planner\Badges\Badge|null The most recently completed badge, or null if none completed.
*/
public function get_latest_completed_badge() {
if ( $this->latest_completed_badge ) {
return $this->latest_completed_badge;
}
- // Get the settings for badges.
+ // Get the settings for badges (stores completion dates).
$settings = \progress_planner()->get_settings()->get( 'badges', [] );
$latest_date = null;
+ // Loop through all badge contexts to find the most recently completed badge.
foreach ( [ 'content', 'maintenance', 'monthly_flat' ] as $context ) {
foreach ( $this->$context as $badge ) {
- // Skip if the badge has no date.
+ // Skip badges that don't have a completion date recorded.
if ( ! isset( $settings[ $badge->get_id() ]['date'] ) ) {
continue;
}
$badge_progress = $badge->get_progress();
- // Continue if the badge is not completed.
+ // Skip badges that aren't 100% complete.
if ( 100 > (int) $badge_progress['progress'] ) {
continue;
}
- // Set the first badge as the latest.
+ // Initialize with the first completed badge found.
if ( null === $latest_date ) {
$this->latest_completed_badge = $badge;
if ( isset( $settings[ $badge->get_id() ]['date'] ) ) {
@@ -193,7 +209,8 @@ public function get_latest_completed_badge() {
continue;
}
- // Compare dates.
+ // Compare completion dates as Unix timestamps to find the most recent.
+ // Using >= ensures that if multiple badges complete simultaneously, the last one processed wins.
if ( \DateTime::createFromFormat( 'Y-m-d H:i:s', $settings[ $badge->get_id() ]['date'] )->format( 'U' ) >= \DateTime::createFromFormat( 'Y-m-d H:i:s', $latest_date )->format( 'U' ) ) {
$latest_date = $settings[ $badge->get_id() ]['date'];
$this->latest_completed_badge = $badge;
diff --git a/classes/class-base.php b/classes/class-base.php
index 2aa14bbd4b..09cf1192fa 100644
--- a/classes/class-base.php
+++ b/classes/class-base.php
@@ -55,6 +55,7 @@
* @method \Progress_Planner\Admin\Widgets\Challenge get_admin__widgets__challenge()
* @method \Progress_Planner\Admin\Widgets\Activity_Scores get_admin__widgets__activity_scores()
* @method \Progress_Planner\Utils\Date get_utils__date()
+ * @method \Progress_Planner\Onboard_Wizard get_onboard_wizard()
*/
class Base {
@@ -86,16 +87,26 @@ class Base {
*/
public function init() {
if ( ! \function_exists( 'current_user_can' ) ) {
- require_once ABSPATH . 'wp-includes/capabilities.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/capabilities.php';
}
if ( ! \function_exists( 'wp_get_current_user' ) ) {
- require_once ABSPATH . 'wp-includes/pluggable.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/pluggable.php';
}
if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) {
$this->get_utils__playground();
}
+ $prpl_license_key = $this->get_license_key();
+ if ( ! $prpl_license_key && 0 !== (int) \progress_planner()->get_ui__branding()->get_branding_id() ) {
+ $prpl_license_key = \progress_planner()->get_utils__onboard()->make_remote_onboarding_request();
+ if ( '' !== $prpl_license_key ) {
+ \update_option( 'progress_planner_license_key', $prpl_license_key, false );
+ }
+ }
+
// Basic classes.
if ( \is_admin() && \current_user_can( 'edit_others_posts' ) ) {
$this->get_admin__page();
@@ -170,48 +181,92 @@ public function init() {
// Init the enqueue class.
$this->get_admin__enqueue()->init();
+
+ // TODO: Decide when this needs to be initialized.
+ $this->get_onboard_wizard();
}
/**
- * Magic method to get properties.
- * We use this to avoid a lot of code duplication.
+ * Magic method to dynamically instantiate and cache plugin classes.
+ *
+ * This method enables lazy-loading of plugin classes using a simple naming convention,
+ * reducing code duplication and improving performance by instantiating classes only when needed.
*
- * Use a double underscore to separate namespaces:
- * - get_foo() will return an instance of Progress_Planner\Foo.
- * - get_foo_bar() will return an instance of Progress_Planner\Foo_Bar.
- * - get_foo_bar__baz() will return an instance of Progress_Planner\Foo_Bar\Baz.
+ * Naming convention and transformation rules:
+ * - Method names must start with 'get_'
+ * - Single underscore (_) = word boundary, becomes uppercase in class name
+ * - Double underscore (__) = namespace separator, becomes backslash (\)
*
- * @param string $name The name of the property.
- * @param array $arguments The arguments passed to the class constructor.
+ * Examples:
+ * ```
+ * get_settings() β Progress_Planner\Settings
+ * get_admin__page() β Progress_Planner\Admin\Page
+ * get_activities__query() β Progress_Planner\Activities\Query
+ * get_suggested_tasks_db() β Progress_Planner\Suggested_Tasks_Db
+ * get_admin__widgets__todo() β Progress_Planner\Admin\Widgets\Todo
+ * ```
*
- * @return mixed
+ * Transformation process:
+ * 1. Remove 'get_' prefix from method name
+ * 2. Split on '__' to separate namespace parts
+ * 3. For each part, split on '_', uppercase first letter of each word, rejoin
+ * 4. Join namespace parts with '\' and prepend 'Progress_Planner\'
+ *
+ * Caching:
+ * - Once instantiated, classes are cached in $this->cached array
+ * - Subsequent calls return the cached instance (singleton pattern per class)
+ * - Cache key is the method name without 'get_' prefix
+ *
+ * Backwards compatibility:
+ * - Deprecated method names are mapped in Deprecations::BASE_METHODS
+ * - Triggers WordPress deprecation notice and redirects to new method
+ *
+ * @param string $name The method name being called (e.g., 'get_admin__page').
+ * @param array $arguments Arguments passed to the method (forwarded to class constructor).
+ *
+ * @return object|null The instantiated class, cached instance, or null if method doesn't start with 'get_'.
*/
public function __call( $name, $arguments ) {
+ // Only handle methods starting with 'get_'.
if ( 0 !== \strpos( $name, 'get_' ) ) {
- return;
+ return null;
}
+
+ // Extract cache key by removing 'get_' prefix.
$cache_name = \substr( $name, 4 );
+
+ // Return cached instance if already instantiated (singleton pattern).
if ( isset( $this->cached[ $cache_name ] ) ) {
return $this->cached[ $cache_name ];
}
+ // Transform method name to fully qualified class name.
+ // Step 1: Split on '__' to get namespace parts (e.g., 'admin__page' β ['admin', 'page']).
$class_name = \implode( '\\', \explode( '__', $cache_name ) );
+ // Step 2: Split each part on '_', capitalize words, and rejoin.
+ // e.g., 'suggested_tasks_db' β 'Suggested_Tasks_Db'.
+ // Then prepend namespace: 'Progress_Planner\Suggested_Tasks_Db'.
$class_name = 'Progress_Planner\\' . \implode( '_', \array_map( 'ucfirst', \explode( '_', $class_name ) ) );
+
+ // Instantiate the class if it exists.
if ( \class_exists( $class_name ) ) {
$this->cached[ $cache_name ] = new $class_name( $arguments );
return $this->cached[ $cache_name ];
}
- // Backwards-compatibility.
+ // Handle deprecated method names for backwards compatibility.
if ( isset( Deprecations::BASE_METHODS[ $name ] ) ) {
- // Deprecated method.
+ // Trigger WordPress deprecation notice.
\_deprecated_function(
\esc_html( $name ),
- \esc_html( Deprecations::BASE_METHODS[ $name ][1] ),
- \esc_html( Deprecations::BASE_METHODS[ $name ][0] )
+ \esc_html( Deprecations::BASE_METHODS[ $name ][1] ), // Version deprecated.
+ \esc_html( Deprecations::BASE_METHODS[ $name ][0] ) // Replacement method.
);
+ // Call the replacement method.
return $this->{Deprecations::BASE_METHODS[ $name ][0]}();
}
+
+ return null;
}
/**
@@ -258,7 +313,16 @@ public function get_activation_date() {
* @return bool
*/
public function is_privacy_policy_accepted() {
- return false !== \get_option( 'progress_planner_license_key', false );
+ return false !== $this->get_license_key();
+ }
+
+ /**
+ * Get the license key.
+ *
+ * @return string|false
+ */
+ public function get_license_key() {
+ return \get_option( 'progress_planner_license_key', false );
}
/**
@@ -361,6 +425,7 @@ public function the_file( $files, $args = [], $get_contents = false ) {
if ( $get_contents ) {
return (string) \ob_get_clean();
}
+ break; // Exit the loop after the first file is found, covers the case when $get_contents is false.
}
}
return '';
@@ -380,7 +445,8 @@ public function get_file_version( $file ) {
// Otherwise, use the plugin header.
if ( ! \function_exists( 'get_file_data' ) ) {
- require_once ABSPATH . 'wp-includes/functions.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/functions.php';
}
if ( ! self::$plugin_version ) {
@@ -497,7 +563,7 @@ public function is_on_progress_planner_dashboard_page() {
* @return bool
*/
public function is_debug_mode_enabled() {
- return ( \defined( 'PRPL_DEBUG' ) && PRPL_DEBUG ) || \get_option( 'prpl_debug' );
+ return ( ( \defined( 'PRPL_DEBUG' ) && PRPL_DEBUG ) || \get_option( 'prpl_debug' ) ) && \current_user_can( 'manage_options' );
}
}
// phpcs:enable Generic.Commenting.Todo
diff --git a/classes/class-lessons.php b/classes/class-lessons.php
index 6117d6214a..f21d5b6411 100644
--- a/classes/class-lessons.php
+++ b/classes/class-lessons.php
@@ -24,7 +24,10 @@ class Lessons {
/**
* Get the items.
*
- * @return array
+ * @return array Array of lesson objects from remote API. Each lesson contains:
+ * - name (string): Lesson title
+ * - settings (array): Lesson configuration including 'id'
+ * - Other lesson-specific fields from remote server
*/
public function get_items() {
return $this->get_remote_api_items();
@@ -33,14 +36,28 @@ public function get_items() {
/**
* Get items from the remote API.
*
- * @return array
+ * Caching strategy:
+ * - Success: Cache for 1 week (WEEK_IN_SECONDS)
+ * - Errors: Cache empty array for 5 minutes to prevent API hammering
+ * - This prevents repeated failed requests while allowing eventual recovery
+ *
+ * Error handling:
+ * - WP_Error responses (network failures, timeouts)
+ * - Non-200 HTTP status codes (404, 500, etc)
+ * - Invalid JSON responses
+ * All errors return empty array and cache for 5 minutes
+ *
+ * @return array Array of lesson objects, or empty array on error. Each lesson contains:
+ * - name (string): Lesson title
+ * - settings (array): Configuration with 'id' and other properties
+ * - Additional fields as provided by remote API
*/
public function get_remote_api_items() {
$url = \progress_planner()->get_remote_server_root_url() . '/wp-json/progress-planner-saas/v1/lessons';
$url = \add_query_arg(
[
'site' => \get_site_url(),
- 'license_key' => \get_option( 'progress_planner_license_key' ),
+ 'license_key' => \progress_planner()->get_license_key(),
],
$url
);
@@ -54,31 +71,50 @@ public function get_remote_api_items() {
$response = \wp_remote_get( $url );
+ // Handle network errors (timeouts, DNS failures, etc).
if ( \is_wp_error( $response ) ) {
\progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS );
return [];
}
+ // Handle HTTP errors (404, 500, etc).
if ( 200 !== (int) \wp_remote_retrieve_response_code( $response ) ) {
\progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS );
return [];
}
+ // Parse and validate JSON response.
$json = \json_decode( \wp_remote_retrieve_body( $response ), true );
if ( ! \is_array( $json ) ) {
\progress_planner()->get_utils__cache()->set( $cache_key, [], 5 * MINUTE_IN_SECONDS );
return [];
}
+ // Cache successful response for one week.
\progress_planner()->get_utils__cache()->set( $cache_key, $json, WEEK_IN_SECONDS );
return $json;
}
/**
- * Get the lessons pagetypes.
+ * Get the lessons pagetypes for use in page type selection.
+ *
+ * Filters lessons based on site configuration:
+ * - If site shows posts on front ('show_on_front' = 'posts'), excludes homepage lesson
+ * - If site has static front page, includes all lessons including homepage
*
- * @return array
+ * @return array Array of pagetype options formatted for dropdown/select fields. Structure:
+ * [
+ * [
+ * 'label' => 'Homepage', // Human-readable lesson name
+ * 'value' => 'homepage' // Lesson ID for storage
+ * ],
+ * [
+ * 'label' => 'About Page',
+ * 'value' => 'about'
+ * ],
+ * ...
+ * ]
*/
public function get_lesson_pagetypes() {
$lessons = $this->get_items();
@@ -87,6 +123,7 @@ public function get_lesson_pagetypes() {
foreach ( $lessons as $lesson ) {
// Remove the "homepage" lesson if the site doesn't show a static page as the frontpage.
+ // Sites showing blog posts on front don't need homepage-specific lessons.
if ( 'posts' === $show_on_front && 'homepage' === $lesson['settings']['id'] ) {
continue;
}
diff --git a/classes/class-onboard-wizard.php b/classes/class-onboard-wizard.php
new file mode 100644
index 0000000000..80a79eedec
--- /dev/null
+++ b/classes/class-onboard-wizard.php
@@ -0,0 +1,603 @@
+get_ui__branding()->get_branding_id();
+ $show_onboarding = ! \progress_planner()->is_privacy_policy_accepted()
+ || \get_option( self::PROGRESS_OPTION_NAME, false )
+ || $is_branded;
+
+ /**
+ * Filter whether to show the onboarding wizard.
+ *
+ * Hosting integrations can use this filter to force showing
+ * or hiding the onboarding wizard.
+ *
+ * @param bool $show_onboarding Whether to show the onboarding wizard.
+ */
+ $show_onboarding = \apply_filters( 'progress_planner_show_onboarding', $show_onboarding );
+
+ if ( ! $show_onboarding ) {
+ return;
+ }
+
+ // Add popover on front end.
+ \add_action( 'wp_footer', [ $this, 'add_popover' ] );
+ \add_action( 'wp_footer', [ $this, 'add_popover_step_templates' ] );
+ \add_action( 'wp_enqueue_scripts', [ $this, 'add_popover_scripts' ] );
+
+ // Add popover on admin.
+ \add_action( 'admin_footer', [ $this, 'add_popover' ] );
+ \add_action( 'admin_footer', [ $this, 'add_popover_step_templates' ] );
+ \add_action( 'admin_enqueue_scripts', [ $this, 'add_popover_scripts' ] );
+
+ // Trigger the onboarding wizard on the front end.
+ \add_action( 'wp_footer', [ $this, 'trigger_onboarding' ] );
+ \add_action( 'admin_footer', [ $this, 'trigger_onboarding' ] );
+
+ // Define steps and their order.
+ \add_action( 'init', [ $this, 'define_steps_and_order' ], 101 );
+
+ // Allow only images for the front-end upload.
+ \add_filter( 'rest_pre_insert_attachment', [ $this, 'rest_pre_insert_attachment' ], 10, 2 );
+ }
+
+ /**
+ * Define steps and their order.
+ *
+ * @return void
+ */
+ public function define_steps_and_order() {
+ $saved_progress = $this->get_saved_progress();
+
+ // We need to know if the first task is already completed, in case user resumes the onboarding.
+ $was_first_task_completed = isset( $saved_progress['data'] ) && ! empty( $saved_progress['data']['firstTaskCompleted'] );
+
+ // Get the onboarding tasks.
+ $onboarding_tasks = [
+ 'core-blogdescription',
+ 'select-timezone',
+ 'select-locale',
+ 'core-siteicon',
+ ];
+
+ $tasks = [];
+
+ foreach ( $onboarding_tasks as $task_id ) {
+ $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] );
+ $task_provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task_id );
+
+ // If there is no task, create it.
+ if ( ! $task && $task_provider ) {
+ $task_data = $task_provider->get_task_details();
+
+ // Task will not be inserted if it already exists.
+ \progress_planner()->get_suggested_tasks_db()->add( $task_data );
+
+ // Now get the task.
+ $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'task_id' => $task_id ] );
+ }
+
+ // Safety check: Skip if task could not be created or retrieved.
+ if ( empty( $task ) ) {
+ \error_log( 'Onboarding: Could not retrieve or create task: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ continue;
+ }
+
+ $task_formatted = [
+ 'task_id' => $task[0]->get_task_id(),
+ 'title' => $task[0]->post_title ?? '',
+ 'url' => $task[0]->url ?? '',
+ 'provider_id' => $task[0]->get_provider_id(),
+ 'points' => $task[0]->points ?? 0,
+ 'action_label' => $task_provider ? $task_provider->get_task_action_label() : \esc_html__( 'Do it', 'progress-planner' ),
+ ];
+
+ // Add task specific data.
+ if ( 'core-blogdescription' === $task_id ) {
+ $task_formatted['site_description'] = \get_bloginfo( 'description' );
+ }
+
+ $tasks[ $task_id ] = $task_formatted;
+ }
+
+ $this->steps = [
+ [
+ 'script_file_name' => 'WelcomeStep',
+ 'template_file_name' => 'welcome',
+ 'template_id' => 'onboarding-step-welcome',
+ /* translators: %s: Progress Planner name. */
+ 'title' => sprintf( esc_html__( 'Welcome to %s', 'progress-planner' ), \esc_html( \progress_planner()->get_ui__branding()->get_admin_menu_name() ) ),
+ ],
+ [
+ 'script_file_name' => 'WhatsWhatStep',
+ 'template_file_name' => 'whats-what',
+ 'template_id' => 'onboarding-step-whats-what',
+ 'title' => esc_html__( 'What\'s what?', 'progress-planner' ),
+ ],
+ ];
+
+ // Add first task step if there are tasks or if the first task is already completed.
+ if ( ! empty( $tasks ) || $was_first_task_completed ) {
+ $this->steps[] = [
+ 'script_file_name' => 'FirstTaskStep',
+ 'template_file_name' => 'first-task',
+ 'template_data' => ! $was_first_task_completed ? [ 'task' => \array_shift( $tasks ) ] : [],
+ 'template_id' => 'onboarding-step-first-task',
+ 'title' => esc_html__( 'Complete your first task!', 'progress-planner' ),
+ ];
+ }
+
+ $this->steps[] = [
+ 'script_file_name' => 'BadgesStep',
+ 'template_file_name' => 'badges',
+ 'template_id' => 'onboarding-step-badges',
+ 'title' => esc_html__( 'Our badges are waiting for you', 'progress-planner' ),
+ ];
+
+ $this->steps[] = [
+ 'script_file_name' => 'EmailFrequencyStep',
+ 'template_file_name' => 'email-frequency',
+ 'template_id' => 'onboarding-step-email-frequency',
+ 'title' => esc_html__( 'Email Frequency', 'progress-planner' ),
+ ];
+
+ $this->steps[] = [
+ 'script_file_name' => 'SettingsStep',
+ 'template_file_name' => 'settings',
+ 'template_id' => 'onboarding-step-settings',
+ 'title' => esc_html__( 'Settings', 'progress-planner' ),
+ ];
+
+ // Add more-tasks step if there are remaining tasks.
+ if ( ! empty( $tasks ) ) {
+ $this->steps[] = [
+ 'script_file_name' => 'MoreTasksStep',
+ 'template_file_name' => 'more-tasks',
+ 'template_data' => [ 'tasks' => $tasks ],
+ 'template_id' => 'onboarding-step-more-tasks',
+ 'title' => esc_html__( 'Finish onboarding!', 'progress-planner' ),
+ ];
+ }
+ }
+
+ /**
+ * Allow only images for the front-end upload.
+ *
+ * @param array $attachment The attachment.
+ * @param \WP_REST_Request $request The request.
+ * @return array|\WP_Error The attachment or WP_Error.
+ */
+ public function rest_pre_insert_attachment( $attachment, $request ) {
+
+ // Only run for our file upload.
+ if ( isset( $request['prplFileUpload'] ) && $request['prplFileUpload'] ) {
+
+ $files = $request->get_file_params();
+
+ if ( empty( $files['file'] ) ) {
+ return new \WP_Error(
+ 'rest_no_file',
+ __( 'No file uploaded.', 'progress-planner' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $file = $files['file'];
+
+ // Check MIME type.
+ if ( strpos( $file['type'], 'image/' ) !== 0 ) {
+ return new \WP_Error(
+ 'rest_invalid_file_type',
+ __( 'Only images are allowed for this upload.', 'progress-planner' ),
+ [ 'status' => 400 ]
+ );
+ }
+ }
+
+ return $attachment;
+ }
+
+ /**
+ * Add popover scripts.
+ *
+ * @return void
+ */
+ public function add_popover_scripts() {
+ // Enqueue variables-color.css.
+ \wp_enqueue_style( 'prpl-variables-color', \constant( 'PROGRESS_PLANNER_URL' ) . '/assets/css/variables-color.css', [], \progress_planner()->get_plugin_version() );
+
+ \wp_add_inline_style( 'prpl-variables-color', \progress_planner()->get_ui__branding()->get_custom_css() );
+
+ // Enqueue onboarding.css.
+ progress_planner()->get_admin__enqueue()->enqueue_style( 'onboarding/onboarding' );
+
+ // Enqueue PrplOnboardTask (used by MoreTasksStep).
+ \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/OnboardTask' );
+
+ // Enqueue base step class.
+ \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/steps/OnboardingStep' );
+
+ // Enqueue step components.
+ foreach ( $this->steps as $step ) {
+ \progress_planner()->get_admin__enqueue()->enqueue_script( 'onboarding/steps/' . $step['script_file_name'] );
+ }
+
+ \progress_planner()->get_admin__enqueue()->enqueue_script( 'web-components/prpl-gauge' );
+
+ // Get saved progress from user meta.
+ $saved_progress = $this->get_saved_progress();
+
+ // Enqueue main onboarding.js (depends on all step components).
+ \progress_planner()->get_admin__enqueue()->enqueue_script(
+ 'onboarding/onboarding',
+ [
+ 'name' => 'ProgressPlannerOnboardData',
+ 'data' => [
+ 'adminAjaxUrl' => \esc_url_raw( admin_url( 'admin-ajax.php' ) ),
+ 'nonceProgressPlanner' => \esc_js( \wp_create_nonce( 'progress_planner' ) ),
+ 'nonceWPAPI' => \esc_js( \wp_create_nonce( 'wp_rest' ) ),
+ 'popoverId' => 'prpl-popover-onboarding',
+ 'onboardAPIUrl' => \progress_planner()->get_utils__onboard()->get_remote_url( 'onboard' ),
+ 'onboardNonceURL' => \progress_planner()->get_utils__onboard()->get_remote_url( 'get-nonce' ),
+ 'site' => \esc_attr( \set_url_scheme( \site_url() ) ),
+ 'timezone_offset' => (float) ( \wp_timezone()->getOffset( new \DateTime( 'midnight' ) ) / 3600 ),
+ 'savedProgress' => $saved_progress,
+ 'lastStepRedirectUrl' => \esc_url_raw( admin_url( 'admin.php?page=progress-planner' ) ),
+ 'fullscreenMode' => true, // Enable fullscreen takeover mode.
+ 'hasLicense' => false !== \progress_planner()->get_license_key(),
+ 'l10n' => [
+ 'next' => \esc_html__( 'Next', 'progress-planner' ),
+ 'startOnboarding' => \esc_html__( 'Start onboarding', 'progress-planner' ),
+ /* translators: %s: Progress Planner name. */
+ 'privacyPolicyError' => sprintf( \esc_html__( 'You need to agree with the privacy policy to use the %s plugin.', 'progress-planner' ), \esc_html( \progress_planner()->get_ui__branding()->get_admin_menu_name() ) ),
+ 'dashboard' => \esc_html__( 'Take me to the dashboard', 'progress-planner' ),
+ 'backToRecommendations' => \esc_html__( 'Back to recommendations', 'progress-planner' ),
+ ],
+ 'errorIcon' => \progress_planner()->get_asset( 'images/icon_exclamation_circle.svg' ),
+ 'steps' => array_column( $this->steps, 'script_file_name' ),
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Get saved progress from user meta.
+ *
+ * @return array|null
+ */
+ protected function get_saved_progress() {
+ if ( ! \get_current_user_id() ) {
+ return null;
+ }
+
+ $onboarding_progress = \get_option( self::PROGRESS_OPTION_NAME, true );
+ if ( ! $onboarding_progress ) {
+ return null;
+ }
+
+ $decoded = \json_decode( $onboarding_progress, true );
+ if ( ! $decoded || ! \is_array( $decoded ) ) {
+ return null;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * Verify AJAX security (capability and nonce).
+ *
+ * @return void
+ */
+ protected function verify_ajax_security() {
+ if ( ! \current_user_can( 'manage_options' ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to perform this action.', 'progress-planner' ) ] );
+ }
+
+ if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+ }
+ }
+
+ /**
+ * Save the tour progress.
+ *
+ * @return void
+ */
+ public function ajax_save_onboarding_progress() {
+ $this->verify_ajax_security();
+
+ if ( ! isset( $_POST['state'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+ \wp_send_json_error( [ 'message' => \esc_html__( 'State is required.', 'progress-planner' ) ] );
+ }
+ $progress = \sanitize_text_field( \wp_unslash( $_POST['state'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+
+ \error_log( print_r( $progress, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, WordPress.PHP.DevelopmentFunctions.error_log_error_log
+
+ // Save as user meta?
+ \update_option( self::PROGRESS_OPTION_NAME, $progress );
+
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Tour progress saved.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Complete a task.
+ *
+ * @return void
+ */
+ public function ajax_complete_task() {
+ $this->verify_ajax_security();
+
+ if ( ! isset( $_POST['task_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Task ID is required.', 'progress-planner' ) ] );
+ }
+
+ $task_id = \sanitize_text_field( \wp_unslash( $_POST['task_id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+
+ // Aditional data for the task, besides the task ID.
+ $form_values = [];
+ if ( isset( $_POST['form_values'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+ $form_values = \sanitize_text_field( \wp_unslash( $_POST['form_values'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax_security().
+ $form_values = \json_decode( $form_values, true );
+ }
+
+ // Safety check: Ensure form_values is an array after decoding.
+ if ( ! \is_array( $form_values ) ) {
+ $form_values = [];
+ }
+
+ // Get the task.
+ $task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id );
+ if ( ! $task ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Task not found.', 'progress-planner' ) ] );
+ }
+
+ // To get the provider and complete the task, we need to use the provider.
+ $provider = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->get_task_provider( $task->get_provider_id() );
+ if ( ! $provider ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Provider not found.', 'progress-planner' ) ] );
+ }
+
+ // Complete the task.
+ $task_completed = $provider->complete_task( $form_values, $task_id );
+
+ // It will skip the celebration and set the task's post status to trash.
+ $task_post_marked_as_completed = \progress_planner()->get_suggested_tasks()->mark_task_as_completed( $task_id, null, true );
+
+ if ( ! $task_completed || ! $task_post_marked_as_completed ) {
+ \error_log( 'Task not completed: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Task not completed.', 'progress-planner' ) ] );
+ }
+
+ \error_log( 'Task completed: ' . $task_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Task completed.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Handle saving all onboarding settings in a single request.
+ *
+ * @return void
+ */
+ public function ajax_save_all_onboarding_settings() {
+ $this->verify_ajax_security();
+
+ $page_settings = \progress_planner()->get_admin__page_settings();
+
+ // Handle page settings (about, contact, faq).
+ if ( isset( $_POST['pages'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $pages_json = \sanitize_text_field( \wp_unslash( $_POST['pages'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $pages = \json_decode( $pages_json, true );
+
+ if ( \is_array( $pages ) ) {
+ // Convert to the format expected by set_page_values.
+ $pages_formatted = [];
+ foreach ( $pages as $page_type => $page_data ) {
+ if ( isset( $page_data['id'] ) && isset( $page_data['have_page'] ) ) {
+ $pages_formatted[ $page_type ] = [
+ 'id' => (int) $page_data['id'],
+ 'have_page' => $page_data['have_page'],
+ ];
+ }
+ }
+
+ if ( ! empty( $pages_formatted ) ) {
+ $page_settings->set_page_values( $pages_formatted );
+ }
+ }
+ }
+
+ // Handle post types.
+ $include_post_types = isset( $_POST['prpl-post-types-include'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ ? \array_map( 'sanitize_text_field', \wp_unslash( $_POST['prpl-post-types-include'] ) ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
+ : [];
+ $page_settings->save_post_types( $include_post_types );
+
+ // Handle login destination.
+ $redirect_on_login = isset( $_POST['prpl-redirect-on-login'] ) ? (bool) \sanitize_text_field( \wp_unslash( $_POST['prpl-redirect-on-login'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $page_settings->save_settings( $redirect_on_login );
+
+ \wp_send_json_success( [ 'message' => \esc_html__( 'All settings saved successfully.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Trigger the onboarding wizard on the front end.
+ *
+ * @return void
+ */
+ public function trigger_onboarding() {
+
+ // If the request is an AJAX request, do not trigger the onboarding wizard.
+ if ( \wp_doing_ajax() ) {
+ return;
+ }
+
+ // Dont trigger it if user is not logged in and is not a admin.
+ if ( ! \is_user_logged_in() || ! \current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $get_saved_progress = $this->get_saved_progress();
+
+ // If there is no saved progress, trigger the onboarding wizard.
+ if ( ! $get_saved_progress ) {
+ ?>
+
+
+
+
+
+
+
+
+ steps[0]['title'] ); ?>
+
+
+ steps as $step ) :
+ ?>
+
+
+ [] ] ); ?>
+
+
+
+
+
+ get_ui__branding()->the_logo(); ?>
+
+
+
+
+
+
+
+
+
+
+
+ steps as $step ) {
+ \progress_planner()->the_view( 'onboarding/' . $step['template_file_name'] . '.php', isset( $step['template_data'] ) ? $step['template_data'] : [] );
+ }
+
+ // Add quit confirmation template.
+ \progress_planner()->the_view( 'onboarding/quit-confirmation.php' );
+ ?>
+
+ [ $homepage_id ],
'contact' => $this->get_posts_by_title( \__( 'Contact', 'progress-planner' ) ),
'about' => $this->get_posts_by_title( \__( 'About', 'progress-planner' ) ),
'faq' => \array_merge(
+ // FAQ can match either short form or long form.
$this->get_posts_by_title( \__( 'FAQ', 'progress-planner' ) ),
$this->get_posts_by_title( \__( 'Frequently Asked Questions', 'progress-planner' ) ),
),
@@ -315,29 +318,34 @@ public function get_default_page_id_by_type( $page_type ) {
$defined_page_types = \array_keys( $types_pages );
- // If the page type is not among defined page types, return 0.
+ // Validate that the requested page type exists in our definitions.
if ( ! \in_array( $page_type, $defined_page_types, true ) ) {
return 0;
}
- // Get the posts for the page-type.
+ // Get candidate pages for the requested page type.
$posts = $types_pages[ $page_type ];
- // If we have no posts, return 0.
+ // No candidates found for this page type.
if ( empty( $posts ) ) {
return 0;
}
- // Exclude the homepage and any pages that are already assigned to another page-type.
+ // Apply exclusion logic: Remove pages that are already assigned to OTHER page types.
+ // This ensures each page is only assigned to one page type, preventing conflicts.
+ // Example: If page ID 5 matches both "About" and "Contact", only the first checked type claims it.
foreach ( $defined_page_types as $defined_page_type ) {
- // Skip the current page-type.
+ // Skip the current page-type (we don't want to exclude our own candidates).
if ( $page_type === $defined_page_type ) {
continue;
}
+ // Remove any page IDs that belong to other page types.
+ // array_diff removes values from $posts that exist in $types_pages[$defined_page_type].
$posts = \array_diff( $posts, $types_pages[ $defined_page_type ] );
}
+ // Return the first remaining candidate, or 0 if all were excluded.
return empty( $posts ) ? 0 : $posts[0];
}
diff --git a/classes/class-plugin-upgrade-tasks.php b/classes/class-plugin-upgrade-tasks.php
index 3603a40cd8..a111e79065 100644
--- a/classes/class-plugin-upgrade-tasks.php
+++ b/classes/class-plugin-upgrade-tasks.php
@@ -83,8 +83,9 @@ public function handle_activation_or_upgrade() {
* @return void
*/
protected function add_initial_onboarding_tasks() {
- // Privacy policy is not accepted, so it's a fresh install.
- $fresh_install = ! \progress_planner()->is_privacy_policy_accepted();
+ // Check if this is a fresh install (not a re-activation).
+ // If the option doesn't exist, it's a fresh install.
+ $fresh_install = false === \get_option( 'progress_planner_previous_version_task_providers', false );
// If this is the first time the plugin is installed, save the task providers.
if ( $fresh_install ) {
diff --git a/classes/class-suggested-tasks-db.php b/classes/class-suggested-tasks-db.php
index 3cda4c8490..e1444bf08e 100644
--- a/classes/class-suggested-tasks-db.php
+++ b/classes/class-suggested-tasks-db.php
@@ -24,11 +24,40 @@ class Suggested_Tasks_DB {
const GET_TASKS_CACHE_GROUP = 'progress_planner_get_tasks';
/**
- * Add a recommendation.
+ * Add a recommendation (suggested task).
*
- * @param array $data The data to add.
+ * Creates a new task post with proper locking to prevent race conditions when
+ * multiple processes try to create the same task simultaneously.
*
- * @return int
+ * Locking mechanism:
+ * - Uses WordPress options table as a distributed lock via add_option()
+ * - add_option() is atomic: returns false if the option already exists
+ * - Lock key format: "prpl_task_lock_{task_id}"
+ * - Lock value: Current Unix timestamp (for staleness detection)
+ * - Stale lock timeout: 30 seconds (prevents deadlocks from crashed processes)
+ * - Lock is always released in finally block (even if insertion fails)
+ *
+ * This ensures only one process can create a specific task at a time,
+ * preventing duplicate task creation in concurrent scenarios like:
+ * - Multiple cron jobs running simultaneously
+ * - AJAX requests firing in parallel
+ * - Plugin activation on multisite networks
+ *
+ * @param array $data {
+ * The task data to add.
+ *
+ * @type string $task_id Required. The unique task ID (e.g., "update-core").
+ * @type string $post_title Required. The task title shown to users.
+ * @type string $provider_id Required. The provider ID (e.g., "update-core").
+ * @type string $description Optional. The task description/content.
+ * @type int $priority Optional. Display priority (lower = higher priority).
+ * @type int $order Optional. Menu order (defaults to priority if not set).
+ * @type int $parent Optional. Parent task ID for hierarchical tasks.
+ * @type string $post_status Optional. Task status: 'publish', 'pending', 'completed', 'trash', 'snoozed'.
+ * @type int $time Optional. Unix timestamp for snoozed tasks (when to show again).
+ * }
+ *
+ * @return int The created post ID, or 0 if creation failed or task already exists.
*/
public function add( $data ) {
if ( empty( $data['post_title'] ) ) {
@@ -36,29 +65,37 @@ public function add( $data ) {
return 0;
}
+ // Acquire a distributed lock to prevent race conditions during task creation.
$lock_key = 'prpl_task_lock_' . $data['task_id'];
$lock_value = \time();
- // add_option will return false if the option is already there.
+ // Try to create the lock atomically using add_option().
+ // This returns false if the option already exists, indicating another process holds the lock.
if ( ! \add_option( $lock_key, $lock_value, '', false ) ) {
$current = \get_option( $lock_key );
- // If lock is stale (older than 30s), take over.
+ // Check if the lock is stale (older than 30 seconds).
+ // This prevents deadlocks if a process crashes while holding the lock.
if ( $current && ( $current < \time() - 30 ) ) {
\update_option( $lock_key, $lock_value );
} else {
- return 0; // Other process is using it.
+ // Lock is held by another active process, abort to avoid duplicates.
+ return 0;
}
}
- // Check if we have an existing task with the same title.
- $posts = $this->get_tasks_by(
+ // Check if we have an existing task with the same ID.
+ // Search across all post statuses since WordPress 'any' excludes trash and pending.
+ $posts = $this->get_tasks_by(
[
- 'post_status' => [ 'publish', 'trash', 'draft', 'future', 'pending' ], // 'any' doesn't include statuses which have 'exclude_from_search' set to true (trash and pending).
+ 'post_status' => [ 'publish', 'trash', 'draft', 'future', 'pending' ],
'numberposts' => 1,
'name' => \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $data['task_id'] ),
]
);
+
+ // Also check for trashed tasks with the "__trashed" suffix.
+ // This suffix is appended when tasks are permanently removed to preserve history.
$posts_trashed = $this->get_tasks_by(
[
'post_status' => [ 'trash' ],
@@ -67,11 +104,12 @@ public function add( $data ) {
]
);
+ // If no active task exists but a trashed one does, use the trashed one.
if ( empty( $posts ) && ! empty( $posts_trashed ) ) {
$posts = $posts_trashed;
}
- // If we have an existing task, skip.
+ // If task already exists (in any status), return its ID without creating a duplicate.
if ( ! empty( $posts ) ) {
\delete_option( $lock_key );
return $posts[0]->ID;
@@ -153,7 +191,8 @@ public function add( $data ) {
\update_post_meta( $post_id, "prpl_$key", $value );
}
} finally {
- // Delete the lock. This executes always.
+ // Always release the lock, even if an exception occurred during post creation.
+ // This ensures the lock doesn't remain indefinitely and block future attempts.
\delete_option( $lock_key );
}
diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php
index c79e308ece..f2dd4d9b28 100644
--- a/classes/class-suggested-tasks.php
+++ b/classes/class-suggested-tasks.php
@@ -213,19 +213,40 @@ public function maybe_complete_task() {
return;
}
+ // Mark task as completed and delete the token.
+ $this->mark_task_as_completed( $task_id, $user_id );
+ }
+
+ /**
+ * Complete a task.
+ *
+ * @param string $task_id The task ID.
+ * @param int|null $user_id Optional. The user ID for token deletion. If provided, the token will be deleted.
+ * @param bool $skip_celebration Optional. Whether to skip the celebration.
+ *
+ * @return bool
+ */
+ public function mark_task_as_completed( $task_id, $user_id = null, $skip_celebration = false ) {
if ( ! $this->was_task_completed( $task_id ) ) {
$task = \progress_planner()->get_suggested_tasks_db()->get_post( $task_id );
if ( $task ) {
- \progress_planner()->get_suggested_tasks_db()->update_recommendation( $task->ID, [ 'post_status' => 'pending' ] );
+ $post_status = $skip_celebration ? 'trash' : 'pending';
+ \progress_planner()->get_suggested_tasks_db()->update_recommendation( $task->ID, [ 'post_status' => $post_status ] );
// Insert an activity.
$this->insert_activity( $task_id );
- // Delete the token after successful use (one-time use).
- $this->delete_task_completion_token( $task_id, $user_id );
+ // Delete the token after successful use (one-time use) if user_id is provided.
+ if ( $user_id ) {
+ $this->delete_task_completion_token( $task_id, $user_id );
+ }
+
+ return true;
}
}
+
+ return false;
}
/**
@@ -479,7 +500,9 @@ public function rest_api_tax_query( $args, $request ) {
// Handle sorting parameters.
if ( isset( $request['filter']['orderby'] ) ) {
- $args['orderby'] = \sanitize_sql_orderby( $request['filter']['orderby'] );
+ // @phpstan-ignore-next-line argument.templateType
+ $orderby = \sanitize_sql_orderby( $request['filter']['orderby'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $args['orderby'] = $orderby !== false ? $orderby : 'date';
}
if ( isset( $request['filter']['order'] ) ) {
$args['order'] = \in_array( \strtoupper( $request['filter']['order'] ), [ 'ASC', 'DESC' ], true )
diff --git a/classes/class-todo.php b/classes/class-todo.php
index 281c1f3275..9236bbdd01 100644
--- a/classes/class-todo.php
+++ b/classes/class-todo.php
@@ -26,12 +26,30 @@ public function __construct() {
}
/**
- * Maybe change the points of the first item in the todo list on Monday.
+ * Mark the first task in the todo list as "GOLDEN" for bonus points.
+ *
+ * The GOLDEN task concept:
+ * - The first task in the user's todo list receives special "GOLDEN" status
+ * - Completing a GOLDEN task awards bonus points to encourage task completion
+ * - The GOLDEN status is stored in the post_excerpt field with the value "GOLDEN"
+ * - Only one task can be GOLDEN at a time (all others have empty post_excerpt)
+ *
+ * Weekly reset mechanism:
+ * - Runs automatically on Monday of each week
+ * - Re-evaluates which task should be GOLDEN based on current todo list order
+ * - If tasks are reordered during the week, the GOLDEN status updates on next Monday
+ * - Uses a transient cache to prevent running more than once per week
+ * - Cache key: 'todo_points_change_on_monday', expires next Monday
+ *
+ * This encourages users to:
+ * - Prioritize their most important task each week
+ * - Maintain an active todo list
+ * - Complete tasks in a strategic order
*
* @return void
*/
public function maybe_change_first_item_points_on_monday() {
- // Ordered by menu_order ASC, by default.
+ // Get all user-created tasks, ordered by menu_order ASC (task priority).
$pending_items = \progress_planner()->get_suggested_tasks_db()->get_tasks_by(
[
'provider_id' => 'user',
@@ -39,11 +57,12 @@ public function maybe_change_first_item_points_on_monday() {
]
);
- // Bail if there are no items.
+ // Bail if there are no tasks to process.
if ( ! \count( $pending_items ) ) {
return;
}
+ // Check if we've already updated this week (prevents multiple runs).
$transient_name = 'todo_points_change_on_monday';
$next_update = \progress_planner()->get_utils__cache()->get( $transient_name );
@@ -51,9 +70,11 @@ public function maybe_change_first_item_points_on_monday() {
return;
}
+ // Calculate next Monday's timestamp for the cache expiration.
$next_monday = new \DateTime( 'monday next week' );
- // Reset the points of all the tasks, except for the first one in the todo list.
+ // Update GOLDEN status: First task gets 'GOLDEN', all others get empty string.
+ // This ensures only the highest-priority task awards bonus points.
foreach ( $pending_items as $task ) {
\progress_planner()->get_suggested_tasks_db()->update_recommendation(
$task->ID,
@@ -61,17 +82,25 @@ public function maybe_change_first_item_points_on_monday() {
);
}
+ // Cache the next update time to prevent re-running until next Monday.
\progress_planner()->get_utils__cache()->set( $transient_name, $next_monday->getTimestamp(), WEEK_IN_SECONDS );
}
/**
- * Handle the creation of the first user task.
- * We need separate hook, since at the time 'maybe_change_first_item_points_on_monday' is called there might not be any tasks yet.
- * TODO: Revisit when we see how we handle completed user tasks.
+ * Handle the creation of user tasks and assign GOLDEN status if appropriate.
+ *
+ * This runs after a task is created via the REST API. We need this separate hook
+ * because `maybe_change_first_item_points_on_monday()` runs on 'init', which happens
+ * before any tasks exist on first plugin activation.
+ *
+ * GOLDEN task assignment:
+ * - If this is the very first user task created, it immediately becomes GOLDEN
+ * - This provides instant bonus points for users starting their first task
+ * - Subsequent tasks follow the normal Monday reset cycle
*
* @param \WP_Post $post Inserted or updated post object.
* @param \WP_REST_Request $request Request object.
- * @param bool $creating True when creating a post, false when updating.
+ * @param bool $creating True when creating a new task, false when updating existing.
*
* @return void
*/
diff --git a/classes/goals/class-goal-recurring.php b/classes/goals/class-goal-recurring.php
index d70fc2de87..6a5cb3e822 100644
--- a/classes/goals/class-goal-recurring.php
+++ b/classes/goals/class-goal-recurring.php
@@ -143,35 +143,68 @@ public function get_occurences() {
}
/**
- * Get the streak for weekly posts.
+ * Calculate streak statistics for recurring goals.
*
- * @return array
+ * Streak calculation algorithm:
+ * 1. Iterate through all goal occurrences in chronological order
+ * 2. For each occurrence, check if the goal was met (evaluate() returns true)
+ * 3. If met: Increment current streak counter and update max streak if needed
+ * 4. If not met: Check if "allowed breaks" remain
+ * - If yes: Use one allowed break and continue streak (decrement allowed_break)
+ * - If no: Reset current streak to 0 (streak is broken)
+ *
+ * Allowed breaks feature:
+ * - Provides flexibility by allowing streaks to survive missed goals
+ * - Example: With 1 allowed break, missing one week won't reset the streak
+ * - The $allowed_break value is modified during iteration (decremented when used)
+ * - Once all breaks are consumed, any further miss resets the streak
+ *
+ * Streak types:
+ * - Current streak: Consecutive goals met from the most recent occurrence backwards
+ * - Max streak: Longest consecutive run of met goals in the entire history
+ *
+ * Example:
+ * Goals: [β, β, β, β, β, β] with 1 allowed break
+ * - Current streak: 3 (last 3 goals met)
+ * - Max streak: 5 (streak continues through the β using the allowed break)
+ *
+ * @return array {
+ * Streak statistics and goal metadata.
+ *
+ * @type int $max_streak The longest streak achieved (consecutive goals met).
+ * @type int $current_streak Current active streak (from most recent backwards).
+ * @type string $title The goal title.
+ * @type string $description The goal description.
+ * }
*/
public function get_streak() {
- // Reverse the order of the occurences.
+ // Get all occurrences of this recurring goal.
$occurences = $this->get_occurences();
- // Calculate the streak number.
- $streak_nr = 0;
- $max_streak = 0;
+ // Initialize streak counters.
+ $streak_nr = 0; // Current ongoing streak.
+ $max_streak = 0; // Best streak ever achieved.
+
foreach ( $occurences as $occurence ) {
- /**
- * Evaluate the occurence.
- * If the occurence is true, then increment the streak number.
- * Otherwise, reset the streak number.
- */
+ // Check if this occurrence's goal was met.
$evaluation = $occurence->evaluate();
+
if ( $evaluation ) {
+ // Goal was met: Increment streak and track if it's a new record.
++$streak_nr;
$max_streak = \max( $max_streak, $streak_nr );
continue;
}
+ // Goal was not met: Check if we can use an allowed break.
if ( $this->allowed_break > 0 ) {
+ // Use one allowed break to keep the streak alive.
+ // This prevents the streak from resetting for this missed goal.
--$this->allowed_break;
continue;
}
+ // No allowed breaks remaining: Streak is broken, reset to 0.
$streak_nr = 0;
}
diff --git a/classes/rest/class-base.php b/classes/rest/class-base.php
index 073d4f020e..be58f286bf 100644
--- a/classes/rest/class-base.php
+++ b/classes/rest/class-base.php
@@ -12,6 +12,22 @@
*/
abstract class Base {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ \add_action( 'rest_api_init', [ $this, 'register_rest_endpoint' ] );
+ }
+
+ /**
+ * Register REST endpoint.
+ *
+ * Child classes must implement this method to define their REST endpoints.
+ *
+ * @return void
+ */
+ abstract public function register_rest_endpoint();
+
/**
* Get client IP address.
*
@@ -70,7 +86,7 @@ public function validate_token( $token ) {
return true;
}
- $license_key = \get_option( 'progress_planner_license_key', false );
+ $license_key = \progress_planner()->get_license_key();
if ( ! $license_key || 'no-license' === $license_key ) {
// Increment failed attempts counter.
\set_transient( $rate_limit_key, $failed_attempts + 1, HOUR_IN_SECONDS );
diff --git a/classes/rest/class-recommendations-controller.php b/classes/rest/class-recommendations-controller.php
index c8028faed7..4a2bcca473 100644
--- a/classes/rest/class-recommendations-controller.php
+++ b/classes/rest/class-recommendations-controller.php
@@ -13,15 +13,34 @@
class Recommendations_Controller extends \WP_REST_Posts_Controller {
/**
- * Get the item schema.
- * We need to add the "trash" status to the allowed enum list for status.
+ * Get the item schema for recommendations (tasks) in the REST API.
*
- * @return array The item schema.
+ * Extends the default WordPress post schema to support the 'trash' status,
+ * which WordPress REST API normally excludes from the allowed enum values.
+ *
+ * This is necessary because Progress Planner uses 'trash' status to indicate:
+ * - Completed tasks (when dismissed/marked complete)
+ * - Deleted tasks (when removed from the list)
+ *
+ * Without this modification, API clients couldn't set tasks to 'trash' status,
+ * preventing proper task completion tracking.
+ *
+ * @return array {
+ * The complete item schema with Progress Planner customizations.
+ * Inherits all WordPress post schema properties plus:
+ *
+ * @type array $properties {
+ * @type array $status {
+ * @type array $enum Allowed status values, now includes 'trash'.
+ * }
+ * }
+ * }
*/
public function get_item_schema() {
$schema = parent::get_item_schema();
// Add "trash" to the allowed enum list for status.
+ // This enables API clients to mark tasks as complete by setting status to 'trash'.
if ( isset( $schema['properties']['status']['enum'] ) ) {
$schema['properties']['status']['enum'][] = 'trash';
}
@@ -30,17 +49,30 @@ public function get_item_schema() {
}
/**
- * Prepare the items query.
- * We only need to add the filter to the query.
+ * Prepare the WP_Query arguments before fetching tasks via REST API.
+ *
+ * This method allows other parts of the plugin (or external code) to modify
+ * the query parameters before tasks are fetched from the database.
+ *
+ * The `rest_prpl_recommendations_query` filter enables:
+ * - Filtering tasks by custom meta fields
+ * - Changing query order or pagination
+ * - Adding tax_query or meta_query clauses
+ * - Customizing which tasks appear in API responses
+ *
+ * @param array $prepared_args {
+ * WP_Query arguments prepared by WordPress REST API.
+ * Common parameters include post_type, post_status, posts_per_page, etc.
+ * }.
+ * @param \WP_REST_Request $request The REST API request object containing query parameters.
*
- * @param array $prepared_args The prepared arguments.
- * @param \WP_REST_Request $request The request.
- * @return array The prepared arguments.
+ * @return array Modified WP_Query arguments ready for database query.
*/
protected function prepare_items_query( $prepared_args = [], $request = null ) {
$prepared_args = parent::prepare_items_query( $prepared_args, $request );
- // Reapply the original filter so your existing filters still run.
+ // Apply filter to allow customization of the query before execution.
+ // This preserves backward compatibility with any existing filters on this hook.
return \apply_filters( 'rest_prpl_recommendations_query', $prepared_args, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
}
}
diff --git a/classes/rest/class-stats.php b/classes/rest/class-stats.php
index d63b124e34..5ff4217bb9 100644
--- a/classes/rest/class-stats.php
+++ b/classes/rest/class-stats.php
@@ -18,12 +18,6 @@
* Rest_API_Stats class.
*/
class Stats extends Base {
- /**
- * Constructor.
- */
- public function __construct() {
- \add_action( 'rest_api_init', [ $this, 'register_rest_endpoint' ] );
- }
/**
* Register the REST-API endpoint.
diff --git a/classes/rest/class-tasks.php b/classes/rest/class-tasks.php
index e45bde627f..7e4849320e 100644
--- a/classes/rest/class-tasks.php
+++ b/classes/rest/class-tasks.php
@@ -14,12 +14,6 @@
* Rest_API_Tasks class.
*/
class Tasks extends Base {
- /**
- * Constructor.
- */
- public function __construct() {
- \add_action( 'rest_api_init', [ $this, 'register_rest_endpoint' ] );
- }
/**
* Register the REST-API endpoint.
diff --git a/classes/suggested-tasks/class-task.php b/classes/suggested-tasks/class-task.php
index aadebf1383..c85db2fa62 100644
--- a/classes/suggested-tasks/class-task.php
+++ b/classes/suggested-tasks/class-task.php
@@ -102,16 +102,25 @@ public function delete(): void {
/**
* Check if the task is snoozed.
*
- * @return bool
+ * A task is snoozed when its post_status is 'future', meaning it's scheduled
+ * to reappear at a later date (the snooze duration selected by the user).
+ *
+ * @return bool True if snoozed, false otherwise.
*/
public function is_snoozed(): bool {
return isset( $this->data['post_status'] ) && 'future' === $this->data['post_status'];
}
/**
- * Get the snoozed until date.
+ * Get the date when a snoozed task will reappear.
+ *
+ * Return values explained:
+ * - DateTime object: Task is snoozed and will reappear on this date
+ * - null: Task is not snoozed (no post_date set)
+ * - false: post_date exists but couldn't be parsed (invalid format) - this is from DateTime::createFromFormat()
*
- * @return \DateTime|null|false
+ * @return \DateTime|null|false DateTime when task will un-snooze, null if not snoozed,
+ * false if date format is invalid.
*/
public function snoozed_until() {
return isset( $this->data['post_date'] ) ? \DateTime::createFromFormat( 'Y-m-d H:i:s', $this->data['post_date'] ) : null;
@@ -120,7 +129,15 @@ public function snoozed_until() {
/**
* Check if the task is completed.
*
- * @return bool
+ * Task completion statuses:
+ * - 'trash': Task was explicitly completed/dismissed by the user
+ * - 'pending': Task is in celebration mode (completed but showing celebration UI)
+ *
+ * Note: 'pending' being treated as completed is counterintuitive but intentional.
+ * It represents tasks that were completed and are now in a temporary "celebrate"
+ * state before being fully archived. This allows showing congratulations UI.
+ *
+ * @return bool True if completed (trash or pending status), false otherwise.
*/
public function is_completed(): bool {
return isset( $this->data['post_status'] ) && \in_array( $this->data['post_status'], [ 'trash', 'pending' ], true );
@@ -183,12 +200,14 @@ public function get_rest_formatted_data( $post_id = null ): array {
// Make sure WP_REST_Posts_Controller is loaded.
if ( ! \class_exists( 'WP_REST_Posts_Controller' ) ) {
- require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php';
}
// Make sure WP_REST_Request is loaded.
if ( ! \class_exists( 'WP_REST_Request' ) ) {
- require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-includes/rest-api/class-wp-rest-request.php';
}
// Use the appropriate controller for the post type.
diff --git a/classes/suggested-tasks/class-tasks-interface.php b/classes/suggested-tasks/class-tasks-interface.php
index 18b3f8f3aa..bf9a9ca598 100644
--- a/classes/suggested-tasks/class-tasks-interface.php
+++ b/classes/suggested-tasks/class-tasks-interface.php
@@ -114,6 +114,13 @@ public function get_popover_id();
*/
public function add_task_actions( $data = [], $actions = [] );
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label();
+
/**
* Check if the task has activity.
*
@@ -122,4 +129,14 @@ public function add_task_actions( $data = [], $actions = [] );
* @return bool
*/
public function task_has_activity( $task_id = '' );
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' );
}
diff --git a/classes/suggested-tasks/class-tasks-manager.php b/classes/suggested-tasks/class-tasks-manager.php
index d20164df94..880e5223e2 100644
--- a/classes/suggested-tasks/class-tasks-manager.php
+++ b/classes/suggested-tasks/class-tasks-manager.php
@@ -11,7 +11,6 @@
use Progress_Planner\Suggested_Tasks\Providers\Content_Create;
use Progress_Planner\Suggested_Tasks\Providers\Content_Review;
use Progress_Planner\Suggested_Tasks\Providers\Blog_Description;
-use Progress_Planner\Suggested_Tasks\Providers\Settings_Saved;
use Progress_Planner\Suggested_Tasks\Providers\Debug_Display;
use Progress_Planner\Suggested_Tasks\Providers\Disable_Comments;
use Progress_Planner\Suggested_Tasks\Providers\Disable_Comment_Pagination;
@@ -33,12 +32,16 @@
use Progress_Planner\Suggested_Tasks\Providers\Fewer_Tags;
use Progress_Planner\Suggested_Tasks\Providers\Remove_Terms_Without_Posts;
use Progress_Planner\Suggested_Tasks\Providers\Update_Term_Description;
+use Progress_Planner\Suggested_Tasks\Providers\Reduce_Autoloaded_Options;
use Progress_Planner\Suggested_Tasks\Providers\Unpublished_Content;
use Progress_Planner\Suggested_Tasks\Providers\Collaborator;
use Progress_Planner\Suggested_Tasks\Providers\Select_Timezone;
use Progress_Planner\Suggested_Tasks\Providers\Set_Date_Format;
use Progress_Planner\Suggested_Tasks\Providers\SEO_Plugin;
use Progress_Planner\Suggested_Tasks\Providers\Improve_Pdf_Handling;
+use Progress_Planner\Suggested_Tasks\Providers\Set_Page_About;
+use Progress_Planner\Suggested_Tasks\Providers\Set_Page_FAQ;
+use Progress_Planner\Suggested_Tasks\Providers\Set_Page_Contact;
/**
* Tasks_Manager class.
@@ -62,7 +65,6 @@ public function __construct() {
new Content_Review(),
new Core_Update(),
new Blog_Description(),
- new Settings_Saved(),
new Debug_Display(),
new Disable_Comments(),
new Disable_Comment_Pagination(),
@@ -74,6 +76,7 @@ public function __construct() {
new Permalink_Structure(),
new Php_Version(),
new Search_Engine_Visibility(),
+ new Reduce_Autoloaded_Options(),
new User_Tasks(),
new Email_Sending(),
new Set_Valuable_Post_Types(),
@@ -87,6 +90,9 @@ public function __construct() {
new Set_Date_Format(),
new SEO_Plugin(),
new Improve_Pdf_Handling(),
+ new Set_Page_About(),
+ new Set_Page_FAQ(),
+ new Set_Page_Contact(),
];
// Add the plugin integration.
diff --git a/classes/suggested-tasks/data-collector/class-base-data-collector.php b/classes/suggested-tasks/data-collector/class-base-data-collector.php
index bf6af2d17d..903d03fae4 100644
--- a/classes/suggested-tasks/data-collector/class-base-data-collector.php
+++ b/classes/suggested-tasks/data-collector/class-base-data-collector.php
@@ -104,4 +104,43 @@ protected function set_cached_data( string $key, $value ) {
$data[ $key ] = $value;
\progress_planner()->get_settings()->set( static::CACHE_KEY, $data );
}
+
+ /**
+ * Get filtered public taxonomies.
+ *
+ * Returns public taxonomies with exclusions applied via filter.
+ *
+ * @return array Array of public taxonomy names.
+ */
+ protected function get_filtered_public_taxonomies() {
+ /**
+ * Array of public taxonomy names where both keys and values are taxonomy names.
+ *
+ * @var array $public_taxonomies
+ */
+ $public_taxonomies = \get_taxonomies( [ 'public' => true ], 'names' );
+
+ /**
+ * Array of public taxonomies to exclude from queries.
+ *
+ * @var array $exclude_public_taxonomies
+ */
+ $exclude_public_taxonomies = \apply_filters(
+ 'progress_planner_exclude_public_taxonomies',
+ [
+ 'post_format',
+ 'product_shipping_class',
+ 'prpl_recommendations_provider',
+ 'gblocks_pattern_collections',
+ ]
+ );
+
+ foreach ( $exclude_public_taxonomies as $taxonomy ) {
+ if ( isset( $public_taxonomies[ $taxonomy ] ) ) {
+ unset( $public_taxonomies[ $taxonomy ] );
+ }
+ }
+
+ return $public_taxonomies;
+ }
}
diff --git a/classes/suggested-tasks/data-collector/class-inactive-plugins.php b/classes/suggested-tasks/data-collector/class-inactive-plugins.php
index 2495f48da6..abcb264766 100644
--- a/classes/suggested-tasks/data-collector/class-inactive-plugins.php
+++ b/classes/suggested-tasks/data-collector/class-inactive-plugins.php
@@ -47,7 +47,8 @@ public function update_inactive_plugins_cache() {
*/
protected function calculate_data() {
if ( ! \function_exists( 'get_plugins' ) ) {
- require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
// Clear the plugins cache, so get_plugins() returns the latest plugins.
diff --git a/classes/suggested-tasks/data-collector/class-seo-plugin.php b/classes/suggested-tasks/data-collector/class-seo-plugin.php
index 32dc072071..ac33e62484 100644
--- a/classes/suggested-tasks/data-collector/class-seo-plugin.php
+++ b/classes/suggested-tasks/data-collector/class-seo-plugin.php
@@ -43,6 +43,12 @@ class SEO_Plugin extends Base_Data_Collector {
'constants' => [ 'AIOSEO_VERSION', 'AIOSEO_FILE' ],
'classes' => [ 'AIOSEO\Plugin\AIOSEO', 'AIOSEOPro\Plugin\AIOSEO' ],
],
+ 'surerank' => [
+ 'name' => 'SureRank SEO',
+ 'slug' => 'surerank',
+ 'constants' => [ 'SURERANK_VERSION', 'SURERANK_FILE' ],
+ 'classes' => [ 'SureRank\Loader', 'SureRank\Inc\Admin\Dashboard' ],
+ ],
];
/**
diff --git a/classes/suggested-tasks/data-collector/class-terms-without-description.php b/classes/suggested-tasks/data-collector/class-terms-without-description.php
index de3f66b381..88814e9e7c 100644
--- a/classes/suggested-tasks/data-collector/class-terms-without-description.php
+++ b/classes/suggested-tasks/data-collector/class-terms-without-description.php
@@ -76,34 +76,8 @@ public function update_terms_without_description_cache() {
protected function calculate_data() {
global $wpdb;
- // Get registered and public taxonomies.
- /**
- * Array of public taxonomy names where both keys and values are taxonomy names.
- *
- * @var array $public_taxonomies
- */
- $public_taxonomies = \get_taxonomies( [ 'public' => true ], 'names' );
-
- /**
- * Array of public taxonomies to exclude from the terms without description query.
- *
- * @var array $exclude_public_taxonomies
- */
- $exclude_public_taxonomies = \apply_filters(
- 'progress_planner_exclude_public_taxonomies',
- [
- 'post_format',
- 'product_shipping_class',
- 'prpl_recommendations_provider',
- 'gblocks_pattern_collections',
- ]
- );
-
- foreach ( $exclude_public_taxonomies as $taxonomy ) {
- if ( isset( $public_taxonomies[ $taxonomy ] ) ) {
- unset( $public_taxonomies[ $taxonomy ] );
- }
- }
+ // Get registered and public taxonomies with exclusions applied.
+ $public_taxonomies = $this->get_filtered_public_taxonomies();
// Exclude the Uncategorized category.
$uncategorized_category_id = ( new Uncategorized_Category() )->collect();
diff --git a/classes/suggested-tasks/data-collector/class-terms-without-posts.php b/classes/suggested-tasks/data-collector/class-terms-without-posts.php
index 1e1de96af7..12af3aa1b2 100644
--- a/classes/suggested-tasks/data-collector/class-terms-without-posts.php
+++ b/classes/suggested-tasks/data-collector/class-terms-without-posts.php
@@ -85,34 +85,8 @@ public function update_terms_without_posts_cache() {
protected function calculate_data() {
global $wpdb;
- // Get registered and public taxonomies.
- /**
- * Array of public taxonomy names where both keys and values are taxonomy names.
- *
- * @var array $public_taxonomies
- */
- $public_taxonomies = \get_taxonomies( [ 'public' => true ], 'names' );
-
- /**
- * Array of public taxonomies to exclude from the terms without posts query.
- *
- * @var array $exclude_public_taxonomies
- */
- $exclude_public_taxonomies = \apply_filters(
- 'progress_planner_exclude_public_taxonomies',
- [
- 'post_format',
- 'product_shipping_class',
- 'prpl_recommendations_provider',
- 'gblocks_pattern_collections',
- ]
- );
-
- foreach ( $exclude_public_taxonomies as $taxonomy ) {
- if ( isset( $public_taxonomies[ $taxonomy ] ) ) {
- unset( $public_taxonomies[ $taxonomy ] );
- }
- }
+ // Get registered and public taxonomies with exclusions applied.
+ $public_taxonomies = $this->get_filtered_public_taxonomies();
/**
* Array of term IDs to exclude from the terms without description query.
diff --git a/classes/suggested-tasks/providers/class-blog-description.php b/classes/suggested-tasks/providers/class-blog-description.php
index 61ec8bb519..add79362a3 100644
--- a/classes/suggested-tasks/providers/class-blog-description.php
+++ b/classes/suggested-tasks/providers/class-blog-description.php
@@ -142,9 +142,42 @@ public function print_popover_form_contents() {
public function add_task_actions( $data = [], $actions = [] ) {
$actions[] = [
'priority' => 10,
- 'html' => '' . \esc_html__( 'Set tagline', 'progress-planner' ) . ' ',
+ 'html' => '' . \esc_html( $this->get_task_action_label() ) . ' ',
];
return $actions;
}
+
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label() {
+ return \__( 'Set tagline', 'progress-planner' );
+ }
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' ) {
+
+ if ( ! $this->capability_required() ) {
+ return false;
+ }
+
+ if ( ! isset( $args['blogdescription'] ) ) {
+ return false;
+ }
+
+ // update_option will return false if the option value is the same as the one being set.
+ \update_option( 'blogdescription', \sanitize_text_field( $args['blogdescription'] ) );
+
+ return true;
+ }
}
diff --git a/classes/suggested-tasks/providers/class-core-update.php b/classes/suggested-tasks/providers/class-core-update.php
index 714ace08ea..0a681b4373 100644
--- a/classes/suggested-tasks/providers/class-core-update.php
+++ b/classes/suggested-tasks/providers/class-core-update.php
@@ -107,7 +107,8 @@ public function add_core_update_link( $update_actions ) {
public function should_add_task() {
// Without this \wp_get_update_data() might not return correct data for the core updates (depending on the timing).
if ( ! \function_exists( 'get_core_updates' ) ) {
- require_once ABSPATH . 'wp-admin/includes/update.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/update.php';
}
// For wp_get_update_data() to return correct data it needs to be called after the 'admin_init' action (with priority 10).
diff --git a/classes/suggested-tasks/providers/class-fewer-tags.php b/classes/suggested-tasks/providers/class-fewer-tags.php
index 643e3cb6c6..21a929474c 100644
--- a/classes/suggested-tasks/providers/class-fewer-tags.php
+++ b/classes/suggested-tasks/providers/class-fewer-tags.php
@@ -154,7 +154,8 @@ public function is_task_completed( $task_id = '' ) {
protected function is_plugin_active() {
if ( null === $this->is_plugin_active ) {
if ( ! \function_exists( 'get_plugins' ) ) {
- require_once ABSPATH . 'wp-admin/includes/plugin.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = \get_plugins();
diff --git a/classes/suggested-tasks/providers/class-improve-pdf-handling.php b/classes/suggested-tasks/providers/class-improve-pdf-handling.php
index 74f3bbcf49..ed036463bb 100644
--- a/classes/suggested-tasks/providers/class-improve-pdf-handling.php
+++ b/classes/suggested-tasks/providers/class-improve-pdf-handling.php
@@ -19,7 +19,6 @@ class Improve_Pdf_Handling extends Tasks_Interactive {
*/
protected const IS_ONBOARDING_TASK = false;
-
/**
* The minimum number of PDF files.
*
diff --git a/classes/suggested-tasks/providers/class-reduce-autoloaded-options.php b/classes/suggested-tasks/providers/class-reduce-autoloaded-options.php
new file mode 100644
index 0000000000..6df0b6f0c1
--- /dev/null
+++ b/classes/suggested-tasks/providers/class-reduce-autoloaded-options.php
@@ -0,0 +1,232 @@
+url = \admin_url( '/plugin-install.php?tab=search&s=aaa+option+optimizer' );
+
+ /**
+ * Filter the autoloaded options threshold.
+ *
+ * @param int $threshold The threshold.
+ *
+ * @return int
+ */
+ $this->autoloaded_options_threshold = (int) \apply_filters( 'progress_planner_reduce_autoloaded_options_threshold', $this->autoloaded_options_threshold );
+ }
+
+ /**
+ * Get the title.
+ *
+ * @return string
+ */
+ public function get_title() {
+ return \esc_html__( 'Reduce number of autoloaded options', 'progress-planner' );
+ }
+
+ /**
+ * Check if the task condition is satisfied.
+ * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ // If the plugin is active, we don't need to add the task.
+ if ( $this->is_plugin_active() ) {
+ return false;
+ }
+
+ return $this->get_autoloaded_options_count() > $this->autoloaded_options_threshold;
+ }
+
+ /**
+ * Check if the task is completed.
+ *
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function is_task_completed( $task_id = '' ) {
+ return $this->is_plugin_active() || $this->get_autoloaded_options_count() <= $this->autoloaded_options_threshold;
+ }
+
+ /**
+ * Check if the plugin is active.
+ *
+ * @return bool
+ */
+ protected function is_plugin_active() {
+
+ if ( null === $this->is_plugin_active ) {
+ if ( ! \function_exists( 'get_plugins' ) ) {
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ $plugins = get_plugins();
+ $this->is_plugin_active = isset( $plugins[ $this->plugin_path ] ) && is_plugin_active( $this->plugin_path );
+ }
+
+ return $this->is_plugin_active;
+ }
+
+ /**
+ * Get the number of autoloaded options.
+ *
+ * @return int
+ */
+ protected function get_autoloaded_options_count() {
+ global $wpdb;
+
+ if ( null === $this->autoloaded_options_count ) {
+ $autoload_values = \wp_autoload_values_to_autoload();
+ $placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) );
+
+ // phpcs:disable WordPress.DB
+ $this->autoloaded_options_count = $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM `{$wpdb->options}` WHERE autoload IN ( $placeholders )", $autoload_values ) // @phpstan-ignore-line property.nonObject
+ );
+
+ }
+
+ return $this->autoloaded_options_count;
+ }
+
+ /**
+ * Get the popover instructions.
+ *
+ * @return void
+ */
+ public function print_popover_instructions() {
+ echo '';
+ \printf(
+ // translators: %d is the number of autoloaded options.
+ \esc_html__( 'There are %d autoloaded options. If you don\'t need them, consider reducing them by installing the "AAA Option Optimizer" plugin.', 'progress-planner' ),
+ (int) $this->get_autoloaded_options_count(),
+ );
+ echo '
';
+ }
+
+ /**
+ * Print the popover input field for the form.
+ *
+ * @return void
+ */
+ public function print_popover_form_contents() {
+ ?>
+
+
+
+ 10,
+ 'html' => '' . \esc_html__( 'Reduce', 'progress-planner' ) . ' ',
+ ];
+
+ return $actions;
+ }
+}
diff --git a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php b/classes/suggested-tasks/providers/class-remove-terms-without-posts.php
index 98ed13ce3b..79c8cf9f2a 100644
--- a/classes/suggested-tasks/providers/class-remove-terms-without-posts.php
+++ b/classes/suggested-tasks/providers/class-remove-terms-without-posts.php
@@ -405,7 +405,7 @@ public function print_popover_form_contents() {
',
diff --git a/classes/suggested-tasks/providers/class-select-locale.php b/classes/suggested-tasks/providers/class-select-locale.php
index 88ac6bfcd7..b15251b393 100644
--- a/classes/suggested-tasks/providers/class-select-locale.php
+++ b/classes/suggested-tasks/providers/class-select-locale.php
@@ -205,7 +205,8 @@ public function print_popover_instructions() {
public function print_popover_form_contents() {
if ( ! \function_exists( 'wp_get_available_translations' ) ) {
- require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/translation-install.php';
}
$languages = \get_available_languages();
@@ -268,26 +269,13 @@ public function handle_interactive_task_specific_submit() {
\wp_send_json_error( [ 'message' => \esc_html__( 'Missing setting path.', 'progress-planner' ) ] );
}
- $option_updated = false;
$language_for_update = \sanitize_text_field( \wp_unslash( $_POST['value'] ) );
if ( empty( $language_for_update ) ) {
\wp_send_json_error( [ 'message' => \esc_html__( 'Invalid language.', 'progress-planner' ) ] );
}
- // Handle translation installation.
- if ( \current_user_can( 'install_languages' ) ) {
- require_once ABSPATH . 'wp-admin/includes/translation-install.php'; // @phpstan-ignore requireOnce.fileNotFound
-
- if ( \wp_can_install_language_pack() ) {
- $language = \wp_download_language_pack( $language_for_update );
- if ( $language ) {
- $language_for_update = $language;
-
- $option_updated = \update_option( 'WPLANG', $language_for_update );
- }
- }
- }
+ $option_updated = $this->update_language( $language_for_update );
if ( $option_updated ) {
\wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
@@ -307,9 +295,68 @@ public function handle_interactive_task_specific_submit() {
public function add_task_actions( $data = [], $actions = [] ) {
$actions[] = [
'priority' => 10,
- 'html' => '' . \esc_html__( 'Select locale', 'progress-planner' ) . ' ',
+ 'html' => '' . \esc_html( $this->get_task_action_label() ) . ' ',
];
return $actions;
}
+
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label() {
+ return \__( 'Select locale', 'progress-planner' );
+ }
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' ) {
+
+ if ( ! $this->capability_required() ) {
+ return false;
+ }
+
+ if ( ! isset( $args['language'] ) ) {
+ return false;
+ }
+
+ return $this->update_language( \sanitize_text_field( \wp_unslash( $args['language'] ) ) );
+ }
+
+ /**
+ * Update the language.
+ *
+ * @param string $language_for_update The language to update.
+ *
+ * @return bool
+ */
+ protected function update_language( $language_for_update ) {
+ // Handle translation installation.
+ if ( \current_user_can( 'install_languages' ) ) {
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/translation-install.php';
+
+ if ( \wp_can_install_language_pack() ) {
+ $language = \wp_download_language_pack( $language_for_update );
+ if ( $language ) {
+ $language_for_update = $language;
+
+ // update_option will return false if the option value is the same as the one being set.
+ \update_option( 'WPLANG', $language_for_update );
+
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
}
diff --git a/classes/suggested-tasks/providers/class-select-timezone.php b/classes/suggested-tasks/providers/class-select-timezone.php
index 3eb54c8bf1..14807d3bfe 100644
--- a/classes/suggested-tasks/providers/class-select-timezone.php
+++ b/classes/suggested-tasks/providers/class-select-timezone.php
@@ -192,6 +192,75 @@ public function handle_interactive_task_specific_submit() {
\wp_send_json_error( [ 'message' => \esc_html__( 'Invalid timezone.', 'progress-planner' ) ] );
}
+ $option_updated = $this->update_timezone( $timezone_string );
+
+ if ( $option_updated ) {
+
+ // We're not checking for the return value of the update_option calls, because it will return false if the value is the same (for example if gmt_offset is already set to '').
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
+ }
+
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Add task actions specific to this task.
+ *
+ * @param array $data The task data.
+ * @param array $actions The existing actions.
+ *
+ * @return array
+ */
+ public function add_task_actions( $data = [], $actions = [] ) {
+ $actions[] = [
+ 'priority' => 10,
+ 'html' => '' . \esc_html( $this->get_task_action_label() ) . ' ',
+ ];
+
+ return $actions;
+ }
+
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label() {
+ return \__( 'Select timezone', 'progress-planner' );
+ }
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' ) {
+
+ if ( ! $this->capability_required() ) {
+ return false;
+ }
+
+ if ( ! isset( $args['timezone'] ) ) {
+ return false;
+ }
+
+ $timezone_string = \sanitize_text_field( \wp_unslash( $args['timezone'] ) );
+
+ return $this->update_timezone( $timezone_string );
+ }
+
+ /**
+ * Update the timezone.
+ *
+ * @param string $timezone_string The timezone string to update.
+ *
+ * @return bool
+ */
+ protected function update_timezone( $timezone_string ) {
+
$update_options = false;
// Map UTC+- timezones to gmt_offsets and set timezone_string to empty.
@@ -214,27 +283,9 @@ public function handle_interactive_task_specific_submit() {
\update_option( 'timezone_string', $timezone_string );
\update_option( 'gmt_offset', $gmt_offset );
- // We're not checking for the return value of the update_option calls, because it will return false if the value is the same (for example if gmt_offset is already set to '').
- \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
+ return true;
}
- \wp_send_json_error( [ 'message' => \esc_html__( 'Failed to update setting.', 'progress-planner' ) ] );
- }
-
- /**
- * Add task actions specific to this task.
- *
- * @param array $data The task data.
- * @param array $actions The existing actions.
- *
- * @return array
- */
- public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Select timezone', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return false;
}
}
diff --git a/classes/suggested-tasks/providers/class-set-date-format.php b/classes/suggested-tasks/providers/class-set-date-format.php
index 909881ef79..da3e42e932 100644
--- a/classes/suggested-tasks/providers/class-set-date-format.php
+++ b/classes/suggested-tasks/providers/class-set-date-format.php
@@ -120,6 +120,11 @@ public function should_add_task() {
public function print_popover_instructions() {
$detected_date_format = $this->get_date_format_type();
+ if ( ! \function_exists( 'wp_get_available_translations' ) ) {
+ // @phpstan-ignore-next-line requireOnce.fileNotFound
+ require_once ABSPATH . 'wp-admin/includes/translation-install.php';
+ }
+
// Get the site default language name.
$available_languages = \wp_get_available_translations();
$site_locale = \get_locale();
diff --git a/classes/suggested-tasks/providers/class-set-page-about.php b/classes/suggested-tasks/providers/class-set-page-about.php
new file mode 100644
index 0000000000..fbf9aa8d66
--- /dev/null
+++ b/classes/suggested-tasks/providers/class-set-page-about.php
@@ -0,0 +1,58 @@
+';
+ \esc_html_e( 'Your About page tells your story. It tells your visitors who you are, what your business is, and why your website exists. It humanizes your business by telling visitors about yourself and your team.', 'progress-planner' );
+ echo '
';
+ echo '';
+ \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' );
+ echo '
';
+ }
+}
diff --git a/classes/suggested-tasks/providers/class-set-page-contact.php b/classes/suggested-tasks/providers/class-set-page-contact.php
new file mode 100644
index 0000000000..235a0e6fc7
--- /dev/null
+++ b/classes/suggested-tasks/providers/class-set-page-contact.php
@@ -0,0 +1,58 @@
+';
+ \esc_html_e( 'A strong contact page is essential for capturing leads and enhancing customer service.', 'progress-planner' );
+ echo '';
+ echo '';
+ \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' );
+ echo '
';
+ }
+}
diff --git a/classes/suggested-tasks/providers/class-set-page-faq.php b/classes/suggested-tasks/providers/class-set-page-faq.php
new file mode 100644
index 0000000000..20f0e1c336
--- /dev/null
+++ b/classes/suggested-tasks/providers/class-set-page-faq.php
@@ -0,0 +1,58 @@
+';
+ \esc_html_e( 'An FAQ page is essential for quickly answering your visitorsβ most common questions. Itβs beneficial for e-commerce sites, where customers frequently have questions about products, orders, and return policies.', 'progress-planner' );
+ echo '';
+ echo '';
+ \esc_html_e( 'You can set this page from the Sidebar on the Page Edit screen.', 'progress-planner' );
+ echo '
';
+ }
+}
diff --git a/classes/suggested-tasks/providers/class-set-page-task.php b/classes/suggested-tasks/providers/class-set-page-task.php
new file mode 100644
index 0000000000..27e8899675
--- /dev/null
+++ b/classes/suggested-tasks/providers/class-set-page-task.php
@@ -0,0 +1,199 @@
+get_admin__enqueue()->enqueue_script(
+ 'progress-planner/recommendations/set-page',
+ $this->get_enqueue_data()
+ );
+ self::$script_enqueued = true;
+ }
+ }
+
+ /**
+ * Check if the task condition is satisfied.
+ * (bool) true means that the task condition is satisfied, meaning that we don't need to add the task or task was completed.
+ *
+ * @return bool
+ */
+ public function should_add_task() {
+ $pages = \progress_planner()->get_admin__page_settings()->get_settings();
+
+ if ( ! isset( $pages[ static::PAGE_NAME ] ) ) {
+ return false;
+ }
+
+ return 'no' === $pages[ static::PAGE_NAME ]['isset'];
+ }
+
+ /**
+ * Print the popover input field for the form.
+ *
+ * @return void
+ */
+ public function print_popover_form_contents() {
+ $pages = \progress_planner()->get_admin__page_settings()->get_settings();
+ $page = $pages[ static::PAGE_NAME ];
+
+ \progress_planner()->the_view(
+ 'setting/page-select.php',
+ [
+ 'prpl_setting' => $page,
+ 'context' => 'popover',
+ ]
+ );
+ $this->print_submit_button( \__( 'Set page', 'progress-planner' ) );
+ }
+
+ /**
+ * Handle the interactive task submit.
+ *
+ * This is only for interactive tasks that change core permalink settings.
+ * The $_POST data is expected to be:
+ * - have_page: (string) The value to update the setting to.
+ * - id: (int) The ID of the page to update.
+ * - task_id: (string) The task ID (e.g., "set-page-about") to identify the page type.
+ * - nonce: (string) The nonce.
+ *
+ * @return void
+ */
+ public static function handle_interactive_task_specific_submit() {
+
+ // Check if the user has the necessary capabilities.
+ if ( ! \current_user_can( 'manage_options' ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] );
+ }
+
+ // Check the nonce.
+ if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+ }
+
+ if ( ! isset( $_POST['have_page'] ) || ! isset( $_POST['task_id'] ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Missing value.', 'progress-planner' ) ] );
+ }
+
+ $have_page = \trim( \sanitize_text_field( \wp_unslash( $_POST['have_page'] ) ) );
+ $id = isset( $_POST['id'] ) ? (int) \trim( \sanitize_text_field( \wp_unslash( $_POST['id'] ) ) ) : 0;
+ $task_id = \trim( \sanitize_text_field( \wp_unslash( $_POST['task_id'] ) ) );
+
+ if ( empty( $have_page ) || empty( $task_id ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid page value.', 'progress-planner' ) ] );
+ }
+
+ // Extract page name from task ID (e.g., "set-page-about" -> "about").
+ $page_name = \str_replace( 'set-page-', '', $task_id );
+ if ( empty( $page_name ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid task ID.', 'progress-planner' ) ] );
+ }
+
+ // Validate page name against allowed page types.
+ $pages = \progress_planner()->get_admin__page_settings()->get_settings();
+ if ( ! isset( $pages[ $page_name ] ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid page name.', 'progress-planner' ) ] );
+ }
+
+ // Update the page value.
+ \progress_planner()->get_admin__page_settings()->set_page_values(
+ [
+ $page_name => [
+ 'id' => (int) $id,
+ 'have_page' => $have_page, // yes, no, not-applicable.
+ ],
+ ]
+ );
+
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Page updated.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Add task actions specific to this task.
+ *
+ * @param array $data The task data.
+ * @param array $actions The existing actions.
+ *
+ * @return array
+ */
+ public function add_task_actions( $data = [], $actions = [] ) {
+ $actions[] = [
+ 'priority' => 10,
+ 'html' => '' . \esc_html__( 'Set', 'progress-planner' ) . ' ',
+ ];
+
+ return $actions;
+ }
+}
diff --git a/classes/suggested-tasks/providers/class-set-valuable-post-types.php b/classes/suggested-tasks/providers/class-set-valuable-post-types.php
index b93aea3054..8cc61a9c76 100644
--- a/classes/suggested-tasks/providers/class-set-valuable-post-types.php
+++ b/classes/suggested-tasks/providers/class-set-valuable-post-types.php
@@ -10,7 +10,7 @@
/**
* Add tasks for settings saved.
*/
-class Set_Valuable_Post_Types extends Tasks {
+class Set_Valuable_Post_Types extends Tasks_Interactive {
/**
* The provider ID.
@@ -19,6 +19,13 @@ class Set_Valuable_Post_Types extends Tasks {
*/
protected const PROVIDER_ID = 'set-valuable-post-types';
+ /**
+ * The provider ID.
+ *
+ * @var string
+ */
+ public const POPOVER_ID = 'set-valuable-post-types';
+
/**
* Whether the task is an onboarding task.
*
@@ -40,33 +47,65 @@ class Set_Valuable_Post_Types extends Tasks {
*/
protected $priority = 70;
- /**
- * Get the task URL.
- *
- * @return string
- */
- protected function get_url() {
- return \admin_url( 'admin.php?page=progress-planner-settings' );
- }
-
/**
* Initialize the task provider.
*
* @return void
*/
public function init() {
- \add_action( 'progress_planner_settings_form_options_stored', [ $this, 'remove_upgrade_option' ] );
+ \add_action( 'wp_ajax_prpl_interactive_task_submit_set-valuable-post-types', [ $this, 'handle_interactive_task_specific_submit' ] );
+
+ // On late init hook we need to check if the public post types are changed.
+ \add_action( 'init', [ $this, 'check_public_post_types' ], PHP_INT_MAX - 1 );
}
/**
- * Remove the upgrade option.
+ * Check if the public post types are changed.
*
* @return void
*/
- public function remove_upgrade_option() {
- if ( true === (bool) \get_option( 'progress_planner_set_valuable_post_types', false ) ) {
- \delete_option( 'progress_planner_set_valuable_post_types' );
+ public function check_public_post_types() {
+ $previosly_set_public_post_types = \array_unique( \get_option( 'progress_planner_public_post_types', [] ) );
+ $public_post_types = \array_unique( \progress_planner()->get_settings()->get_public_post_types() );
+
+ // Sort the public post types.
+ \sort( $previosly_set_public_post_types );
+ \sort( $public_post_types );
+
+ // Compare the previously set public post types with the current public post types.
+ if ( $previosly_set_public_post_types === $public_post_types ) {
+ return;
}
+
+ // Update the previously set public post types.
+ \update_option( 'progress_planner_public_post_types', $public_post_types );
+
+ // Exit if post type was removed, or it is not public anymore, since the user will not to able to make different selection.
+ if ( count( $public_post_types ) < count( $previosly_set_public_post_types ) ) {
+ return;
+ }
+
+ // If we're here that means that there is new public post type.
+
+ // Check if the task exists, if it does and it is published do nothing.
+ $task = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( [ 'provider_id' => static::PROVIDER_ID ] );
+ if ( ! empty( $task ) && 'publish' === $task[0]->post_status ) {
+ return;
+ }
+
+ // If it is trashed, change it's status to publish.
+ if ( ! empty( $task ) && 'trash' === $task[0]->post_status ) {
+ \wp_update_post(
+ [
+ 'ID' => $task[0]->ID,
+ 'post_status' => 'publish',
+ ]
+ );
+ return;
+ }
+
+ // If we're here then we need to add it.
+ \progress_planner()->get_suggested_tasks_db()->add( $this->modify_injection_task_data( $this->get_task_details() ) );
}
/**
@@ -119,6 +158,77 @@ public function is_task_completed( $task_id = '' ) {
return false === \get_option( 'progress_planner_set_valuable_post_types', false );
}
+ /**
+ * Handle the interactive task submit.
+ *
+ * @return void
+ */
+ public function handle_interactive_task_specific_submit() {
+ // Check if the user has the necessary capabilities.
+ if ( ! \current_user_can( 'manage_options' ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'You do not have permission to update settings.', 'progress-planner' ) ] );
+ }
+
+ // Check the nonce.
+ if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+ }
+
+ if ( ! isset( $_POST['prpl-post-types-include'] ) ) {
+ \wp_send_json_error( [ 'message' => \esc_html__( 'Missing post types.', 'progress-planner' ) ] );
+ }
+
+ $post_types = \wp_unslash( $_POST['prpl-post-types-include'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- array elements are sanitized below.
+ $post_types = explode( ',', $post_types );
+ $post_types = array_map( 'sanitize_text_field', $post_types );
+
+ \progress_planner()->get_admin__page_settings()->save_post_types( $post_types );
+
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
+ }
+
+ /**
+ * Print the popover instructions.
+ *
+ * @return void
+ */
+ public function print_popover_instructions() {
+ echo '';
+ \esc_html_e( 'You\'re in control of what counts as valuable content. We\'ll track and reward activity only for the post types you select here.', 'progress-planner' );
+ echo '
';
+ }
+
+ /**
+ * Print the popover form contents.
+ *
+ * @return void
+ */
+ public function print_popover_form_contents() {
+ $prpl_saved_settings = \progress_planner()->get_settings()->get_post_types_names();
+ $prpl_post_types = \progress_planner()->get_settings()->get_public_post_types();
+
+ // Early exit if there are no public post types.
+ if ( empty( $prpl_post_types ) ) {
+ return;
+ }
+ ?>
+
+
+
+
+ />
+ labels->name ); // @phpstan-ignore-line property.nonObject ?>
+
+
+
+ print_submit_button( \__( 'Set', 'progress-planner' ) );
+ }
+
/**
* Add task actions specific to this task.
*
@@ -130,7 +240,7 @@ public function is_task_completed( $task_id = '' ) {
public function add_task_actions( $data = [], $actions = [] ) {
$actions[] = [
'priority' => 10,
- 'html' => '' . \esc_html__( 'Go to the settings page', 'progress-planner' ) . ' ',
+ 'html' => '' . \esc_html__( 'Set', 'progress-planner' ) . ' ',
];
return $actions;
diff --git a/classes/suggested-tasks/providers/class-settings-saved.php b/classes/suggested-tasks/providers/class-settings-saved.php
deleted file mode 100644
index 5fe62f14cf..0000000000
--- a/classes/suggested-tasks/providers/class-settings-saved.php
+++ /dev/null
@@ -1,88 +0,0 @@
-get_settings()->get( 'include_post_types' );
- }
-
- /**
- * Add task actions specific to this task.
- *
- * @param array $data The task data.
- * @param array $actions The existing actions.
- *
- * @return array
- */
- public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Go to the settings page', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
- }
-}
diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php
index 7bf34c85f4..7dfd56f88d 100644
--- a/classes/suggested-tasks/providers/class-site-icon.php
+++ b/classes/suggested-tasks/providers/class-site-icon.php
@@ -154,9 +154,42 @@ protected function get_enqueue_data() {
public function add_task_actions( $data = [], $actions = [] ) {
$actions[] = [
'priority' => 10,
- 'html' => '' . \esc_html__( 'Set site icon', 'progress-planner' ) . ' ',
+ 'html' => '' . \esc_html( $this->get_task_action_label() ) . ' ',
];
return $actions;
}
+
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label() {
+ return \__( 'Set site icon', 'progress-planner' );
+ }
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' ) {
+
+ if ( ! $this->capability_required() ) {
+ return false;
+ }
+
+ if ( ! isset( $args['post_id'] ) ) {
+ return false;
+ }
+
+ // update_option will return false if the option value is the same as the one being set.
+ \update_option( 'site_icon', \sanitize_text_field( $args['post_id'] ) );
+
+ return true;
+ }
}
diff --git a/classes/suggested-tasks/providers/class-tasks-interactive.php b/classes/suggested-tasks/providers/class-tasks-interactive.php
index 9e3c3a54fe..a82278f776 100644
--- a/classes/suggested-tasks/providers/class-tasks-interactive.php
+++ b/classes/suggested-tasks/providers/class-tasks-interactive.php
@@ -164,6 +164,11 @@ protected function get_allowed_interactive_options() {
* @return void
*/
public function add_popover() {
+
+ // Don't add the popover if the task is not published.
+ if ( ! $this->is_task_published() ) {
+ return;
+ }
?>
the_popover_content(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -256,6 +261,11 @@ public function enqueue_scripts( $hook ) {
return;
}
+ // Don't enqueue the script if the task is not published.
+ if ( ! $this->is_task_published() ) {
+ return;
+ }
+
// Enqueue the web component.
\progress_planner()->get_admin__enqueue()->enqueue_script(
'progress-planner/recommendations/' . $this->get_provider_id(),
@@ -271,4 +281,19 @@ public function enqueue_scripts( $hook ) {
protected function get_enqueue_data() {
return [];
}
+
+ /**
+ * Check if the task is published.
+ *
+ * @return bool
+ */
+ public function is_task_published() {
+ $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by(
+ [
+ 'provider' => $this->get_provider_id(),
+ 'post_status' => 'publish',
+ ]
+ );
+ return ! empty( $tasks );
+ }
}
diff --git a/classes/suggested-tasks/providers/class-tasks.php b/classes/suggested-tasks/providers/class-tasks.php
index b49857957a..eed1b097c7 100644
--- a/classes/suggested-tasks/providers/class-tasks.php
+++ b/classes/suggested-tasks/providers/class-tasks.php
@@ -267,20 +267,37 @@ public function get_external_link_url() {
/**
* Get the task ID.
*
- * @param array $task_data Optional data to include in the task ID.
- * @return string
+ * Generates a unique task ID by combining the provider ID with optional task-specific data.
+ * For repetitive tasks, includes the current year-week (YW format) to create weekly instances.
+ *
+ * Example task IDs:
+ * - Non-repetitive: "update-core"
+ * - With post target: "update-post-123"
+ * - With term target: "update-term-5-category"
+ * - Repetitive weekly: "create-post-2025W42"
+ *
+ * @param array $task_data {
+ * Optional data to include in the task ID.
+ *
+ * @type int $target_post_id The ID of the post this task targets.
+ * @type int $target_term_id The ID of the term this task targets.
+ * @type string $target_taxonomy The taxonomy slug for term-based tasks.
+ * }
+ * @return string The generated task ID (e.g., "provider-id-123-202542").
*/
public function get_task_id( $task_data = [] ) {
$parts = [ $this->get_provider_id() ];
// Order is important here, new parameters should be added at the end.
+ // This ensures existing task IDs remain consistent when new fields are added.
$parts[] = $task_data['target_post_id'] ?? false;
$parts[] = $task_data['target_term_id'] ?? false;
$parts[] = $task_data['target_taxonomy'] ?? false;
- // If the task is repetitive, add the date as the last part.
+ // If the task is repetitive, add the date as the last part (format: YYYYWW, e.g., 202542 for week 42 of 2025).
+ // This creates a new task instance each week for repetitive tasks.
$parts[] = $this->is_repetitive() ? \gmdate( 'YW' ) : false;
- // Remove empty parts.
+ // Remove empty parts to keep IDs clean.
$parts = \array_filter( $parts );
return \implode( '-', $parts );
@@ -303,8 +320,19 @@ public function get_data_collector() {
/**
* Get the title with data.
*
- * @param array $task_data Optional data to include in the task.
- * @return string
+ * Allows child classes to generate dynamic task titles based on task-specific data.
+ * For example, "Update post: {post_title}" where {post_title} comes from $task_data.
+ *
+ * @param array $task_data {
+ * Optional data to include in the task title.
+ *
+ * @type int $target_post_id The ID of the post this task targets.
+ * @type string $target_post_title The title of the post this task targets.
+ * @type int $target_term_id The ID of the term this task targets.
+ * @type string $target_term_name The name of the term this task targets.
+ * @type string $target_taxonomy The taxonomy slug for term-based tasks.
+ * }
+ * @return string The task title.
*/
protected function get_title_with_data( $task_data = [] ) {
return $this->get_title();
@@ -313,8 +341,18 @@ protected function get_title_with_data( $task_data = [] ) {
/**
* Get the description with data.
*
- * @param array $task_data Optional data to include in the task.
- * @return string
+ * Allows child classes to generate dynamic task descriptions based on task-specific data.
+ *
+ * @param array $task_data {
+ * Optional data to include in the task description.
+ *
+ * @type int $target_post_id The ID of the post this task targets.
+ * @type string $target_post_title The title of the post this task targets.
+ * @type int $target_term_id The ID of the term this task targets.
+ * @type string $target_term_name The name of the term this task targets.
+ * @type string $target_taxonomy The taxonomy slug for term-based tasks.
+ * }
+ * @return string The task description.
*/
protected function get_description_with_data( $task_data = [] ) {
return $this->get_description();
@@ -323,8 +361,17 @@ protected function get_description_with_data( $task_data = [] ) {
/**
* Get the URL with data.
*
- * @param array $task_data Optional data to include in the task.
- * @return string
+ * Allows child classes to generate dynamic task URLs based on task-specific data.
+ * For example, a link to edit a specific post: "post.php?post={post_id}&action=edit".
+ *
+ * @param array $task_data {
+ * Optional data to include in generating the task URL.
+ *
+ * @type int $target_post_id The ID of the post this task targets.
+ * @type int $target_term_id The ID of the term this task targets.
+ * @type string $target_taxonomy The taxonomy slug for term-based tasks.
+ * }
+ * @return string The task URL (escaped and ready to use).
*/
protected function get_url_with_data( $task_data = [] ) {
return $this->get_url();
@@ -389,11 +436,25 @@ public function is_task_relevant() {
}
/**
- * Evaluate a task.
+ * Evaluate a task to check if it has been completed.
*
- * @param string $task_id The task ID.
+ * This method determines whether a task should be marked as completed and earn points.
+ * It handles both non-repetitive tasks (one-time) and repetitive tasks (weekly).
+ *
+ * Non-repetitive tasks:
+ * - Checks if the task belongs to this provider
+ * - Verifies completion status via is_task_completed()
+ * - Returns the task object if completed, false otherwise
*
- * @return \Progress_Planner\Suggested_Tasks\Task|false The task data or false if the task is not completed.
+ * Repetitive tasks:
+ * - Must be completed within the same week they were created (using YW format: year + week number)
+ * - For example, a task created in week 42 of 2025 must be completed in 2025W42
+ * - This prevents tasks from previous weeks being marked as complete
+ * - Allows child classes to add completion data (e.g., post_id for "create post" tasks)
+ *
+ * @param string $task_id The task ID to evaluate.
+ *
+ * @return \Progress_Planner\Suggested_Tasks\Task|false The task object if completed, false otherwise.
*/
public function evaluate_task( $task_id ) {
// Early bail if the user does not have the capability to manage options.
@@ -407,6 +468,7 @@ public function evaluate_task( $task_id ) {
return false;
}
+ // Handle non-repetitive (one-time) tasks.
if ( ! $this->is_repetitive() ) {
// Collaborator tasks have custom task_ids, so strpos check does not work for them.
if ( ! $task->post_name || ( 0 !== \strpos( $task->post_name, $this->get_task_id() ) && 'collaborator' !== $this->get_provider_id() ) ) {
@@ -415,10 +477,13 @@ public function evaluate_task( $task_id ) {
return $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ? $task : false;
}
+ // Handle repetitive (weekly) tasks.
+ // These tasks must be completed in the same week they were created.
if (
$task->provider &&
$task->provider->slug === $this->get_provider_id() &&
\DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date ) &&
+ // Check if the task was created in the current week (YW format: e.g., 202542 = week 42 of 2025).
\gmdate( 'YW' ) === \gmdate( 'YW', \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date )->getTimestamp() ) && // @phpstan-ignore-line
$this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) )
) {
@@ -590,11 +655,40 @@ public function are_dependencies_satisfied() {
}
/**
- * Get task actions.
+ * Get task actions HTML buttons/links for display in the UI.
*
- * @param array $data The task data.
+ * Generates an array of HTML action buttons that users can interact with for each task.
+ * Actions are ordered by priority (lower numbers appear first).
*
- * @return array
+ * Standard actions include:
+ * - Complete button (priority 20): Marks task as complete and awards points
+ * - Snooze button (priority 30): Postpones task for specified duration (1 week to forever)
+ * - Info/External link (priority 40): Educational content about the task
+ * - Custom actions: Child classes can add via add_task_actions()
+ *
+ * Priority system (0-100, lower = higher priority):
+ * - 0-19: Reserved for critical actions
+ * - 20: Complete action
+ * - 30: Snooze action
+ * - 40: Information/educational links
+ * - 50+: Custom provider-specific actions
+ * - 1000: Default for actions without explicit priority
+ *
+ * @param array $data {
+ * The task data from the REST API response.
+ *
+ * @type int $id The WordPress post ID of the task.
+ * @type string $slug The task slug (post_name).
+ * @type array $title {
+ * @type string $rendered The rendered task title.
+ * }
+ * @type array $content {
+ * @type string $rendered The rendered task description/content.
+ * }
+ * @type array $meta Task metadata (presence checked before processing).
+ * }
+ *
+ * @return array Array of HTML strings for action buttons/links, ordered by priority.
*/
public function get_task_actions( $data = [] ) {
$actions = [];
@@ -602,6 +696,7 @@ public function get_task_actions( $data = [] ) {
return $actions;
}
+ // Add "Mark as complete" button for dismissable tasks (except user-created tasks).
if ( $this->capability_required() && $this->is_dismissable() && 'user' !== static::PROVIDER_ID ) {
$actions[] = [
'priority' => 20,
@@ -609,9 +704,13 @@ public function get_task_actions( $data = [] ) {
];
}
+ // Add "Snooze" button with duration options for snoozable tasks.
if ( $this->capability_required() && $this->is_snoozable() ) {
+ // Build snooze dropdown with custom web component (prpl-tooltip).
$snooze_html = '
' . \esc_html__( 'Snooze', 'progress-planner' ) . ' ' . \esc_html__( 'Snooze', 'progress-planner' ) . ' ';
$snooze_html .= '' . \esc_html__( 'Snooze this task?', 'progress-planner' ) . ' ' . \esc_html__( 'How long?', 'progress-planner' ) . ' › ';
+
+ // Generate radio buttons for snooze duration options.
foreach (
[
'1-week' => \esc_html__( '1 week', 'progress-planner' ),
@@ -630,6 +729,8 @@ public function get_task_actions( $data = [] ) {
];
}
+ // Add educational/informational links.
+ // Prefer external links if provided, otherwise show task description in tooltip.
if ( $this->get_external_link_url() ) {
$actions[] = [
'priority' => 40,
@@ -642,9 +743,11 @@ public function get_task_actions( $data = [] ) {
];
}
- // Add action links only if the user has the capability to perform the task.
+ // Allow child classes to add custom actions (e.g., "Edit Post" for content tasks).
if ( $this->capability_required() ) {
$actions = $this->add_task_actions( $data, $actions );
+
+ // Ensure all actions have priority set and remove empty actions.
foreach ( $actions as $key => $action ) {
$actions[ $key ]['priority'] = $action['priority'] ?? 1000;
if ( ! isset( $action['html'] ) || '' === $action['html'] ) {
@@ -653,9 +756,10 @@ public function get_task_actions( $data = [] ) {
}
}
- // Order actions by priority.
+ // Sort actions by priority (ascending: lower priority values appear first).
\usort( $actions, fn( $a, $b ) => $a['priority'] - $b['priority'] );
+ // Extract just the HTML strings (discard priority metadata).
$return_actions = [];
foreach ( $actions as $action ) {
$return_actions[] = $action['html'];
@@ -676,6 +780,15 @@ public function add_task_actions( $data = [], $actions = [] ) {
return $actions;
}
+ /**
+ * Get the task action label.
+ *
+ * @return string
+ */
+ public function get_task_action_label() {
+ return \__( 'Do it', 'progress-planner' );
+ }
+
/**
* Check if the task has activity.
*
@@ -697,4 +810,16 @@ public function task_has_activity( $task_id = '' ) {
return ! empty( $activity );
}
+
+ /**
+ * Complete the task.
+ *
+ * @param array $args The task data.
+ * @param string $task_id The task ID.
+ *
+ * @return bool
+ */
+ public function complete_task( $args = [], $task_id = '' ) {
+ return false;
+ }
}
diff --git a/classes/suggested-tasks/providers/class-update-term-description.php b/classes/suggested-tasks/providers/class-update-term-description.php
index c5c671e23e..455a5675b7 100644
--- a/classes/suggested-tasks/providers/class-update-term-description.php
+++ b/classes/suggested-tasks/providers/class-update-term-description.php
@@ -389,7 +389,7 @@ public function print_popover_form_contents() {
',
diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php
index d572a10ab3..20cc606e49 100644
--- a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php
+++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php
@@ -8,12 +8,17 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO;
use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO;
/**
* Add task for All in One SEO: disable the author archive.
*/
class Archive_Author extends AIOSEO_Interactive_Provider {
+ use Task_Action_Builder;
+ use Ajax_Security_AIOSEO;
+
/**
* The minimum number of posts with a post format to add the task.
*
@@ -148,19 +153,13 @@ public function print_popover_form_contents() {
* @return void
*/
public function handle_interactive_task_specific_submit() {
- if ( ! \function_exists( 'aioseo' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
- }
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_nonce_or_fail();
- // Check the nonce.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
-
- \aioseo()->options->searchAppearance->archives->author->show = false;
+ \aioseo()->options->searchAppearance->archives->author->show = false; // @phpstan-ignore-line
// Update the option.
- \aioseo()->options->save();
+ \aioseo()->options->save(); // @phpstan-ignore-line
\wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
}
@@ -174,11 +173,6 @@ public function handle_interactive_task_specific_submit() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php
index 86ed48d7b8..2b60d55fc6 100644
--- a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php
+++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php
@@ -7,11 +7,17 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO;
+
/**
* Add task for All in One SEO: disable the date archive.
*/
class Archive_Date extends AIOSEO_Interactive_Provider {
+ use Task_Action_Builder;
+ use Ajax_Security_AIOSEO;
+
/**
* The provider ID.
*
@@ -134,19 +140,13 @@ public function print_popover_form_contents() {
* @return void
*/
public function handle_interactive_task_specific_submit() {
- if ( ! \function_exists( 'aioseo' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
- }
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_nonce_or_fail();
- // Check the nonce.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
-
- \aioseo()->options->searchAppearance->archives->date->show = false;
+ \aioseo()->options->searchAppearance->archives->date->show = false; // @phpstan-ignore-line
// Update the option.
- \aioseo()->options->save();
+ \aioseo()->options->save(); // @phpstan-ignore-line
\wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
}
@@ -160,11 +160,6 @@ public function handle_interactive_task_specific_submit() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php
index ddcfcc05ab..74297316e5 100644
--- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php
+++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php
@@ -8,12 +8,17 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO;
use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO;
/**
* Add task for All in One SEO: disable author RSS feeds.
*/
class Crawl_Settings_Feed_Authors extends AIOSEO_Interactive_Provider {
+ use Task_Action_Builder;
+ use Ajax_Security_AIOSEO;
+
/**
* The minimum number of posts with a post format to add the task.
*
@@ -145,19 +150,13 @@ public function print_popover_form_contents() {
* @return void
*/
public function handle_interactive_task_specific_submit() {
- if ( ! \function_exists( 'aioseo' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
- }
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_nonce_or_fail();
- // Check the nonce.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
-
- \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false;
+ \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; // @phpstan-ignore-line
// Update the option.
- \aioseo()->options->save();
+ \aioseo()->options->save(); // @phpstan-ignore-line
\wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
}
@@ -171,11 +170,6 @@ public function handle_interactive_task_specific_submit() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php
index a0a42e7e53..b942ac4a4c 100644
--- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php
+++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php
@@ -7,11 +7,17 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO;
+
/**
* Add task for All in One SEO: disable global comment RSS feeds.
*/
class Crawl_Settings_Feed_Comments extends AIOSEO_Interactive_Provider {
+ use Task_Action_Builder;
+ use Ajax_Security_AIOSEO;
+
/**
* The provider ID.
*
@@ -113,14 +119,8 @@ public function print_popover_form_contents() {
* @return void
*/
public function handle_interactive_task_specific_submit() {
- if ( ! \function_exists( 'aioseo' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
- }
-
- // Check the nonce.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_nonce_or_fail();
// Global comment feed.
if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments ) { // @phpstan-ignore-line
@@ -147,11 +147,6 @@ public function handle_interactive_task_specific_submit() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php
index c4a445c42d..93b22f6461 100644
--- a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php
+++ b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php
@@ -7,11 +7,17 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO;
+
/**
* Add task for All in One SEO: redirect media/attachment pages.
*/
class Media_Pages extends AIOSEO_Interactive_Provider {
+ use Task_Action_Builder;
+ use Ajax_Security_AIOSEO;
+
/**
* The provider ID.
*
@@ -120,19 +126,13 @@ public function print_popover_form_contents() {
* @return void
*/
public function handle_interactive_task_specific_submit() {
- if ( ! \function_exists( 'aioseo' ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
- }
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_nonce_or_fail();
- // Check the nonce.
- if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) {
- \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
- }
-
- \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment';
+ \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; // @phpstan-ignore-line
// Update the option.
- \aioseo()->options->save();
+ \aioseo()->options->save(); // @phpstan-ignore-line
\wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] );
}
@@ -146,11 +146,6 @@ public function handle_interactive_task_specific_submit() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Redirect', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Redirect', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php
index 367d98ac3d..b4bf9ee3e4 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php
@@ -8,12 +8,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
/**
* Add task for Yoast SEO: disable the author archive.
*/
class Archive_Author extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The minimum number of posts with a post format to add the task.
*
@@ -137,11 +140,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php
index 48173c5f78..0c2b5f2dd0 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php
@@ -7,11 +7,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+
/**
* Add task for Yoast SEO: disable the date archive.
*/
class Archive_Date extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -124,11 +128,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php
index 0041f168c3..f7aad926d1 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php
@@ -8,12 +8,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
use Progress_Planner\Suggested_Tasks\Data_Collector\Archive_Format as Archive_Format_Data_Collector;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
/**
* Add task for Yoast SEO: disable the format archives.
*/
class Archive_Format extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -137,11 +140,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php
index 9867a75bc3..72abfc4358 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php
@@ -7,11 +7,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+
/**
* Add task for Yoast SEO: Remove emoji scripts.
*/
class Crawl_Settings_Emoji_Scripts extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -116,11 +120,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php
index ff550f62e6..d68d98caf8 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php
@@ -8,12 +8,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
/**
* Add task for Yoast SEO: Remove post authors feeds.
*/
class Crawl_Settings_Feed_Authors extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The minimum number of posts with a post format to add the task.
*
@@ -148,11 +151,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php
index 95af3c30ea..c28b88230c 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php
@@ -7,11 +7,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+
/**
* Add task for Yoast SEO: Remove global comment feeds.
*/
class Crawl_Settings_Feed_Global_Comments extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -116,11 +120,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php
index 64371fa8ea..64e4387c46 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php
@@ -7,11 +7,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+
/**
* Add task for Yoast SEO: disable the media pages.
*/
class Media_Pages extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -110,11 +114,6 @@ public function print_popover_form_contents() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php
index e32e20352e..899aab77f6 100644
--- a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php
+++ b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php
@@ -7,11 +7,15 @@
namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast;
+use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder;
+
/**
* Add task for Yoast SEO: set your organization logo.
*/
class Organization_Logo extends Yoast_Interactive_Provider {
+ use Task_Action_Builder;
+
/**
* The provider ID.
*
@@ -228,11 +232,6 @@ protected function get_enqueue_data() {
* @return array
*/
public function add_task_actions( $data = [], $actions = [] ) {
- $actions[] = [
- 'priority' => 10,
- 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . ' ',
- ];
-
- return $actions;
+ return $this->add_popover_action( $actions, \__( 'Set logo', 'progress-planner' ) );
}
}
diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php b/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php
new file mode 100644
index 0000000000..0f0f40cd40
--- /dev/null
+++ b/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php
@@ -0,0 +1,53 @@
+ \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] );
+ }
+ }
+
+ /**
+ * Perform complete AIOSEO AJAX security checks.
+ *
+ * Runs AIOSEO active check, capability check, and nonce verification.
+ * This is a convenience method for AIOSEO interactive tasks.
+ *
+ * @param string $capability The capability to require (default: 'manage_options').
+ * @param string $action The nonce action to verify (default: 'progress_planner').
+ * @param string $field The POST field containing the nonce (default: 'nonce').
+ *
+ * @return void Exits with wp_send_json_error() if any check fails.
+ */
+ protected function verify_aioseo_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) {
+ $this->verify_aioseo_active_or_fail();
+ $this->verify_ajax_security( $capability, $action, $field );
+ }
+}
diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-base.php b/classes/suggested-tasks/providers/traits/class-ajax-security-base.php
new file mode 100644
index 0000000000..6526022904
--- /dev/null
+++ b/classes/suggested-tasks/providers/traits/class-ajax-security-base.php
@@ -0,0 +1,72 @@
+ \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] );
+ }
+ }
+
+ /**
+ * Verify user capabilities or send JSON error and exit.
+ *
+ * Checks if the current user has the specified capability and terminates
+ * execution with a JSON error response if they don't.
+ *
+ * @param string $capability The capability to check (default: 'manage_options').
+ *
+ * @return void Exits with wp_send_json_error() if user lacks capability.
+ */
+ protected function verify_capability_or_fail( $capability = 'manage_options' ) {
+ if ( ! \current_user_can( $capability ) ) {
+ \wp_send_json_error(
+ [ 'message' => \esc_html__( 'You do not have permission to perform this action.', 'progress-planner' ) ]
+ );
+ }
+ }
+
+ /**
+ * Perform all standard AJAX security checks.
+ *
+ * Runs nonce verification and capability check in one call.
+ * Useful for most AJAX handlers that require both checks.
+ *
+ * @param string $capability The capability to require (default: 'manage_options').
+ * @param string $action The nonce action to verify (default: 'progress_planner').
+ * @param string $field The POST field containing the nonce (default: 'nonce').
+ *
+ * @return void Exits with wp_send_json_error() if any check fails.
+ */
+ protected function verify_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) {
+ $this->verify_capability_or_fail( $capability );
+ $this->verify_nonce_or_fail( $action, $field );
+ }
+}
diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php b/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php
new file mode 100644
index 0000000000..2f7714a06c
--- /dev/null
+++ b/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php
@@ -0,0 +1,55 @@
+ \esc_html__( 'Yoast SEO is not active.', 'progress-planner' ) ] );
+ }
+ }
+
+ /**
+ * Perform complete Yoast SEO AJAX security checks.
+ *
+ * Runs Yoast active check, capability check, and nonce verification.
+ * This is a convenience method for Yoast interactive tasks.
+ *
+ * @param string $capability The capability to require (default: 'manage_options').
+ * @param string $action The nonce action to verify (default: 'progress_planner').
+ * @param string $field The POST field containing the nonce (default: 'nonce').
+ *
+ * @return void Exits with wp_send_json_error() if any check fails.
+ */
+ protected function verify_yoast_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) {
+ $this->verify_yoast_active_or_fail();
+ $this->verify_ajax_security( $capability, $action, $field );
+ }
+}
diff --git a/classes/suggested-tasks/providers/traits/class-task-action-builder.php b/classes/suggested-tasks/providers/traits/class-task-action-builder.php
new file mode 100644
index 0000000000..2649d24c07
--- /dev/null
+++ b/classes/suggested-tasks/providers/traits/class-task-action-builder.php
@@ -0,0 +1,68 @@
+ $priority,
+ 'html' => $this->generate_popover_button_html( $label ),
+ ];
+ }
+
+ /**
+ * Generate the HTML for a popover trigger button.
+ *
+ * @param string $label The text to display for the action link.
+ *
+ * @return string The HTML for the popover trigger button.
+ */
+ protected function generate_popover_button_html( $label ) {
+ return \sprintf(
+ '%2$s ',
+ \esc_attr( static::POPOVER_ID ),
+ \esc_html( $label )
+ );
+ }
+
+ /**
+ * Add a popover action to the actions array.
+ *
+ * Convenience method that adds a popover action and returns the modified array.
+ *
+ * @param array $actions The existing actions array.
+ * @param string $label The text to display for the action link.
+ * @param int $priority The priority of the action (default: 10).
+ *
+ * @return array The modified actions array.
+ */
+ protected function add_popover_action( $actions, $label, $priority = 10 ) {
+ $actions[] = $this->create_popover_action( $label, $priority );
+ return $actions;
+ }
+}
diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php
index 71f2b3a05e..587bd452ee 100644
--- a/classes/ui/class-chart.php
+++ b/classes/ui/class-chart.php
@@ -15,8 +15,11 @@ class Chart {
/**
* Build a chart for the stats.
*
- * @param array $args The arguments for the chart.
- * See `get_chart_data` for the available parameters.
+ * @param array $args {
+ * The arguments for the chart. See `get_chart_data` for all available parameters.
+ *
+ * @type string $type Chart type (e.g., 'line', 'bar').
+ * }
*
* @return void
*/
@@ -28,23 +31,49 @@ public function the_chart( $args = [] ) {
/**
* Get data for the chart.
*
- * @param array $args The arguments for the chart.
- * ['items_callback'] The callback to get items.
- * ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering.
- * ['dates_params'] The dates parameters for the query.
- * ['start_date'] The start date for the chart.
- * ['end_date'] The end date for the chart.
- * ['frequency'] The frequency for the chart nodes.
- * ['format'] The format for the label.
+ * Normalized charts:
+ * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries
+ * forward previous period's activities into the current period with decaying values.
+ * This creates a rolling momentum effect where past activities continue to contribute
+ * to current scores, gradually diminishing over time.
+ *
+ * Example: If a user published 10 posts in January, the normalized chart for February
+ * will include both February's new posts plus a decayed value from January's posts.
+ * This encourages consistent activity by showing how past work continues to have impact.
+ *
+ * @param array $args {
+ * The arguments for the chart.
+ *
+ * @type callable $items_callback Callback to fetch items for a date range.
+ * Signature: function( DateTime $start_date, DateTime $end_date ): array
+ * @type callable|null $filter_results Optional callback to filter results after fetching.
+ * Signature: function( array $activities ): array
+ * @type array $dates_params {
+ * Date range and frequency parameters.
*
- * @return array
+ * @type DateTime $start_date The start date for the chart.
+ * @type DateTime $end_date The end date for the chart.
+ * @type string $frequency The frequency for chart nodes (e.g., 'day', 'week', 'month').
+ * @type string $format The label format (e.g., 'Y-m-d', 'M j').
+ * }
+ * @type bool $normalized Whether to use normalized scoring with decay from previous periods.
+ * Default false.
+ * @type callable $color Callback to determine bar/line color.
+ * Signature: function(): string (hex color code)
+ * @type callable $count_callback Callback to calculate score from activities.
+ * Signature: function( array $activities, DateTime|null $date ): int|float
+ * @type int|null $max Optional maximum value for chart scaling.
+ * @type string $type Chart type: 'line' or 'bar'. Default 'line'.
+ * @type array $return_data Which data fields to return in output.
+ * Default ['label', 'score', 'color'].
+ * }
+ *
+ * @return array Array of chart data points, each containing requested fields (label, score, color, etc).
*/
public function get_chart_data( $args = [] ) {
$activities = [];
- /*
- * Set default values for the arguments.
- */
+ // Set default values for the arguments.
$args = \wp_parse_args(
$args,
[
@@ -61,7 +90,7 @@ public function get_chart_data( $args = [] ) {
]
);
- // Get the periods for the chart.
+ // Get the periods for the chart (e.g., months, weeks, days based on frequency).
$periods = \progress_planner()->get_utils__date()->get_periods(
$args['dates_params']['start_date'],
$args['dates_params']['end_date'],
@@ -69,15 +98,25 @@ public function get_chart_data( $args = [] ) {
);
/*
- * "Normalized" charts decay the score of previous months activities,
- * and add them to the current month score.
- * This means that for "normalized" charts, we need to get activities
- * for the month prior to the first period.
+ * For "normalized" charts, implement a decay algorithm:
+ * - Previous period's activities "decay" and carry forward into current period
+ * - This creates momentum: past productivity continues to boost current scores
+ * - We need to fetch activities from the month BEFORE the chart starts
+ * - These previous activities will be added (with decay) to the first period's score
+ *
+ * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute
+ * to February's normalized score.
*/
$previous_period_activities = [];
if ( $args['normalized'] ) {
- $previous_month_start = ( clone $periods[0]['start_date'] )->modify( '-1 month' );
- $previous_month_end = ( clone $periods[0]['start_date'] )->modify( '-1 day' );
+ /**
+ * The start date of the first period.
+ *
+ * @var \DateTime $first_period_start
+ */
+ $first_period_start = $periods[0]['start_date'];
+ $previous_month_start = ( clone $first_period_start )->modify( '-1 month' );
+ $previous_month_end = ( clone $first_period_start )->modify( '-1 day' );
$previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end );
if ( $args['filter_results'] ) {
$activities = $args['filter_results']( $activities );
@@ -92,7 +131,8 @@ public function get_chart_data( $args = [] ) {
$previous_period_activities = $period_data['previous_period_activities'];
$period_data_filtered = [];
foreach ( $args['return_data'] as $key ) {
- $period_data_filtered[ $key ] = $period_data[ $key ];
+ $key_string = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset
+ $period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset
}
$data[] = $period_data_filtered;
}
@@ -101,30 +141,55 @@ public function get_chart_data( $args = [] ) {
}
/**
- * Get the data for a period.
+ * Get the data for a single period in the chart.
*
- * @param array $period The period.
- * @param array $args The arguments for the chart.
- * @param array $previous_period_activities The activities for the previous month.
+ * For normalized charts, this implements the decay algorithm:
+ * 1. Calculate score from current period's activities (normal scoring)
+ * 2. Add decayed score from previous period's activities (normalized bonus)
+ * 3. Save current activities to decay into next period
*
- * @return array
+ * The decay is handled by the count_callback, which typically reduces scores
+ * based on how old the activities are relative to the current period.
+ *
+ * @param array $period {
+ * The time period being processed.
+ *
+ * @type DateTime $start_date Period start date.
+ * @type DateTime $end_date Period end date.
+ * @type string $label Human-readable label for this period.
+ * }
+ * @param array $args The chart arguments (see get_chart_data).
+ * @param array $previous_period_activities Activities from the previous period to apply decay to.
+ *
+ * @return array {
+ * Period data with score and metadata.
+ *
+ * @type string $label Period label (e.g., "Jan 2025").
+ * @type int|float $score Calculated score for this period.
+ * @type string $color Color for this data point.
+ * @type array $previous_period_activities Activities to carry forward to next period.
+ * }
*/
public function get_period_data( $period, $args, $previous_period_activities ) {
- // Get the activities for the period.
+ // Get the activities for the current period.
$activities = $args['items_callback']( $period['start_date'], $period['end_date'] );
- // Filter the results if a callback is provided.
+
+ // Apply optional filtering callback.
if ( $args['filter_results'] ) {
$activities = $args['filter_results']( $activities );
}
- // Calculate the score for the period.
+ // Calculate the base score from current period's activities.
$period_score = $args['count_callback']( $activities, $period['start_date'] );
- // If this is a "normalized" chart, we need to calculate the score for the previous month activities.
+ // For normalized charts, apply decay algorithm.
if ( $args['normalized'] ) {
- // Add the previous month activities to the current month score.
+ // Add decayed score from previous period's activities to current score.
+ // The count_callback determines the decay rate based on activity age.
$period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] );
- // Update the previous month activities for the next iteration of the loop.
+
+ // Save current activities to decay into the next period.
+ // This creates a rolling momentum effect across time periods.
$previous_period_activities = $activities;
}
diff --git a/classes/update/class-update-1100.php b/classes/update/class-update-1100.php
new file mode 100644
index 0000000000..d21a1503f4
--- /dev/null
+++ b/classes/update/class-update-1100.php
@@ -0,0 +1,50 @@
+delete_redirect_on_login_user_meta();
+ }
+
+ /**
+ * Delete the prpl_redirect_on_login user meta for all users.
+ *
+ * The settings page that allowed users to set their login destination
+ * has been removed. This migration deletes the user meta to prevent
+ * users from being redirected to Progress Planner after login.
+ *
+ * @return void
+ */
+ private function delete_redirect_on_login_user_meta() {
+ global $wpdb;
+
+ // Delete the user meta for all users directly from the database.
+ // This is more efficient than looping through all users.
+ $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->usermeta, // @phpstan-ignore-line property.nonObject
+ [ 'meta_key' => 'prpl_redirect_on_login' ], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ [ '%s' ]
+ );
+ }
+}
diff --git a/classes/update/class-update-140.php b/classes/update/class-update-140.php
index 9a25b2c968..aeb83429c4 100644
--- a/classes/update/class-update-140.php
+++ b/classes/update/class-update-140.php
@@ -41,10 +41,12 @@ private function rename_tasks_option() {
// This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks.
$tasks = [];
foreach ( $new_tasks as $new_task ) {
- $tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ) ] = $new_task;
+ $key = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+ $tasks[ $key ] = $new_task;
}
foreach ( $old_tasks as $old_task ) {
- $tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ) ] = $old_task;
+ $key = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset
+ $tasks[ $key ] = $old_task;
}
// Set the tasks option.
diff --git a/classes/utils/class-color-customizer.php b/classes/utils/class-color-customizer.php
deleted file mode 100644
index 6097b86fba..0000000000
--- a/classes/utils/class-color-customizer.php
+++ /dev/null
@@ -1,574 +0,0 @@
-register_hooks();
- }
-
- /**
- * Register the hooks.
- *
- * @return void
- */
- private function register_hooks() {
- \add_action( 'admin_menu', [ $this, 'add_page' ] );
- \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
- \add_action( 'admin_init', [ $this, 'handle_form_submission' ] );
- \add_action( 'admin_head', [ $this, 'add_inline_css' ] );
- }
-
- /**
- * Add the admin page (hidden from menu).
- *
- * @return void
- */
- public function add_page() {
- // Add the page but don't show it in the menu.
- \add_submenu_page(
- 'progress-planner',
- 'Color Customizer',
- 'Color Customizer',
- 'manage_options',
- 'progress-planner-color-customizer',
- [ $this, 'render_page' ]
- );
- }
-
-
- /**
- * Enqueue scripts and styles.
- *
- * @param string $hook The current admin page.
- *
- * @return void
- */
- public function enqueue_assets( $hook ) {
- if ( 'progress-planner_page_progress-planner-color-customizer' !== $hook ) {
- return;
- }
-
- // Enqueue the variables-color.css first.
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/variables-color' );
- \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/admin' );
-
- // Enqueue the color customizer JavaScript.
- \progress_planner()->get_admin__enqueue()->enqueue_script( 'color-customizer' );
-
- // Add custom CSS for the color picker page.
- \wp_add_inline_style( 'progress-planner/admin', $this->get_customizer_css() );
- }
-
- /**
- * Handle form submission.
- *
- * @return void
- */
- public function handle_form_submission() {
- if ( ! isset( $_POST['progress_planner_color_customizer_nonce'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- return;
- }
-
- $nonce = \sanitize_text_field( \wp_unslash( $_POST['progress_planner_color_customizer_nonce'] ) );
- if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $nonce ) ), 'progress_planner_color_customizer' ) ) {
- return;
- }
-
- if ( ! \current_user_can( 'manage_options' ) ) {
- return;
- }
-
- $action = \sanitize_text_field( \wp_unslash( $_POST['action'] ?? '' ) );
-
- switch ( $action ) {
- case 'save_colors':
- $this->save_colors();
- break;
- case 'reset_colors':
- $this->reset_colors();
- break;
- case 'export_colors':
- $this->export_colors();
- break;
- case 'import_colors':
- $this->import_colors();
- break;
- }
- }
-
- /**
- * Save color settings.
- *
- * @return void
- */
- private function save_colors() {
- $colors = [];
- $color_variables = $this->get_color_variables();
-
- foreach ( $color_variables as $section => $variables ) {
- foreach ( $variables as $variable => $default_value ) {
- $key = "color_{$variable}";
- if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
- $post_value = isset( $_POST[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
- $color_value = \sanitize_text_field( \wp_unslash( $post_value ) );
- if ( ! empty( $color_value ) ) {
- $colors[ $variable ] = $color_value;
- }
- }
- }
- }
-
- \update_option( self::OPTION_NAME, $colors );
- \add_action(
- 'admin_notices',
- function () {
- echo '
Colors saved successfully!
';
- }
- );
- }
-
- /**
- * Reset color settings.
- *
- * @return void
- */
- private function reset_colors() {
- \delete_option( self::OPTION_NAME );
- \add_action(
- 'admin_notices',
- function () {
- echo '
Colors reset to defaults!
';
- }
- );
- }
-
- /**
- * Export color settings.
- *
- * @return void
- */
- private function export_colors() {
- $colors = \get_option( self::OPTION_NAME, [] );
- $export_data = [
- 'version' => '1.0',
- 'colors' => $colors,
- 'exported_at' => \current_time( 'mysql' ),
- ];
-
- \header( 'Content-Type: application/json' );
- \header( 'Content-Disposition: attachment; filename="progress-planner-colors.json"' );
- echo \wp_json_encode( $export_data, JSON_PRETTY_PRINT );
- exit;
- }
-
- /**
- * Import color settings.
- *
- * @return void
- */
- private function import_colors() {
- if ( ! isset( $_FILES['color_file'] ) || $_FILES['color_file']['error'] !== UPLOAD_ERR_OK ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
- \add_action(
- 'admin_notices',
- function () {
- echo '
';
- }
- );
- return;
- }
-
- $file_content = \file_get_contents( $_FILES['color_file']['tmp_name'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
- $import_data = \json_decode( $file_content, true );
-
- if ( ! $import_data || ! isset( $import_data['colors'] ) ) {
- \add_action(
- 'admin_notices',
- function () {
- echo '
';
- }
- );
- return;
- }
-
- \update_option( self::OPTION_NAME, $import_data['colors'] );
- \add_action(
- 'admin_notices',
- function () {
- echo '
Colors imported successfully!
';
- }
- );
- }
-
- /**
- * Add inline CSS to override default colors.
- *
- * @return void
- */
- public function add_inline_css() {
- // Hide menu item on all pages.
- echo '';
-
- // Only add inline CSS on the PP pages.
- $current_screen = \get_current_screen();
- if ( ! $current_screen ||
- ( 'toplevel_page_progress-planner' !== $current_screen->id && 'progress-planner_page_progress-planner-settings' !== $current_screen->id )
- ) {
- return;
- }
-
- $colors = \get_option( self::OPTION_NAME, [] );
- if ( empty( $colors ) ) {
- return;
- }
-
- $css = ':root {';
- foreach ( $colors as $variable => $value ) {
- $css .= "\n\t--prpl-{$variable}: {$value};";
- }
- $css .= "\n}";
-
- echo '';
- }
-
- /**
- * Render the admin page.
- *
- * @return void
- */
- public function render_page() {
- $colors = \get_option( self::OPTION_NAME, [] );
- $color_variables = $this->get_color_variables();
-
- ?>
-
-
Progress Planner Color Customizer
-
Customize the colors used throughout the Progress Planner interface. Changes will be applied after you save.
-
-
-
-
-
Import / Export
-
-
-
-
-
-
-
- [
- 'background' => '#f6f7f9',
- 'background-banner' => '#f9b23c',
- ],
- 'Paper' => [
- 'background-paper' => '#fff',
- 'color-border' => '#d1d5db',
- 'color-divider' => '#d1d5db',
- 'color-shadow-paper' => '#000',
- ],
- 'Graph' => [
- 'color-gauge-main' => '#e1e3e7',
- 'graph-color-1' => '#f43f5e',
- 'graph-color-2' => '#faa310',
- 'graph-color-3' => '#14b8a6',
- 'graph-color-4' => '#534786',
- ],
- 'Table' => [
- 'background-table' => '#f6f7f9',
- 'background-top-task' => '#fff9f0',
- 'color-border-top-task' => '#faa310',
- 'color-border-next-top-task' => '#534786',
- 'color-selection-controls-inactive' => '#9ca3af',
- 'color-selection-controls' => '#9ca3af',
- 'color-ui-icon' => '#6b7280',
- 'color-ui-icon-hover' => '#1e40af',
- 'color-ui-icon-hover-fill' => '#effbfe',
- 'color-ui-icon-hover-delete' => '#e73136',
- 'background-point' => '#f9b23c',
- 'text-point' => '#38296d',
- 'background-point-inactive' => '#d1d5db',
- 'text-point-inactive' => '#38296d',
- ],
- 'Text' => [
- 'color-text' => '#4b5563',
- 'color-text-hover' => '#1e40af',
- 'color-headings' => '#38296d',
- 'color-subheadings' => '#38296d',
- 'color-link' => '#1e40af',
- 'color-link-hover' => '#4b5563',
- 'color-link-visited' => '#534786',
- ],
- 'Topics' => [
- 'color-monthly' => '#faa310',
- 'color-streak' => '#faa310',
- 'color-content-badge' => '#faa310',
- 'background-monthly' => '#fff9f0',
- 'background-content' => '#f6f5fb',
- 'background-activity' => '#f2faf9',
- 'background-streak' => '#fff6f7',
- 'background-content-badge' => '#effbfe',
- ],
- 'Alert Success' => [
- 'color-alert-success' => '#16a34a',
- 'color-alert-success-text' => '#14532d',
- 'background-alert-success' => '#f0fdf4',
- ],
- 'Alert Error' => [
- 'color-alert-error' => '#e73136',
- 'color-alert-error-text' => '#7f1d1d',
- 'background-alert-error' => '#fdeded',
- ],
- 'Alert Warning' => [
- 'color-alert-warning' => '#eab308',
- 'color-alert-warning-text' => '#713f12',
- 'background-alert-warning' => '#fefce8',
- ],
- 'Alert Info' => [
- 'color-alert-info' => '#2563eb',
- 'color-alert-info-text' => '#1e3a8a',
- 'background-alert-info' => '#eff6ff',
- ],
- 'Button' => [
- 'color-button-primary' => '#dd324f',
- 'color-button-primary-hover' => '#cf2441',
- 'color-button-primary-shadow' => '#000',
- 'color-button-primary-border' => 'none',
- 'color-button-primary-text' => '#fff',
- ],
- 'Settings Page' => [
- 'color-setting-pages-icon' => '#faa310',
- 'color-setting-posts-icon' => '#534786',
- 'color-setting-login-icon' => '#14b8a6',
- 'background-setting-pages' => '#fff9f0',
- 'background-setting-posts' => '#f6f5fb',
- 'background-setting-login' => '#f2faf9',
- 'color-border-settings' => '#d1d5db',
- ],
- 'Input Field Dropdown' => [
- 'color-field-border' => '#d1d5db',
- 'color-text-placeholder' => '#6b7280',
- 'color-text-dropdown' => '#4b5563',
- 'color-field-shadow' => '#000',
- ],
- ];
- }
-
- /**
- * Normalize color value to 6-digit hex format.
- *
- * @param string $color_value The color value to normalize.
- *
- * @return string
- */
- private function normalize_color_value( $color_value ) {
- // Handle null or empty values.
- if ( empty( $color_value ) ) {
- return '#000000';
- }
-
- // Handle special cases.
- if ( 'none' === $color_value ) {
- return '#000000';
- }
-
- // If it's already a 6-digit hex, return as is.
- if ( \preg_match( '/^#[0-9A-Fa-f]{6}$/', $color_value ) ) {
- return \strtolower( $color_value );
- }
-
- // Convert 3-digit hex to 6-digit.
- if ( \preg_match( '/^#[0-9A-Fa-f]{3}$/', $color_value ) ) {
- $hex = \substr( $color_value, 1 );
- return '#' . \strtolower( $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] );
- }
-
- // If it's not a valid hex color, return black as fallback.
- return '#000000';
- }
-
- /**
- * Get custom CSS for the color customizer page.
- *
- * @return string
- */
- private function get_customizer_css() {
- return '
- .color-section {
- margin-bottom: 30px;
- padding: 20px;
- background: #fff;
- border: 1px solid #ddd;
- border-radius: 4px;
- }
-
- .color-section h2 {
- margin-top: 0;
- color: var(--prpl-color-headings);
- border-bottom: 2px solid var(--prpl-color-border);
- padding-bottom: 10px;
- }
-
- .color-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 20px;
- margin-top: 20px;
- }
-
- .color-field {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-
- .color-field label {
- font-weight: 600;
- color: var(--prpl-color-headings);
- }
-
- .default-value {
- font-weight: normal;
- font-size: 12px;
- color: var(--prpl-color-ui-icon);
- display: block;
- }
-
- .color-picker {
- width: 60px;
- height: 40px;
- border: 1px solid var(--prpl-color-border);
- border-radius: 4px;
- cursor: pointer;
- }
-
- .color-text-input {
- padding: 8px;
- border: 1px solid var(--prpl-color-border);
- border-radius: 4px;
- font-family: monospace;
- font-size: 12px;
- }
-
- .form-actions {
- margin: 30px 0;
- padding: 20px;
- background: #fff;
- border: 1px solid #ddd;
- border-radius: 4px;
- }
-
- .import-export-section {
- margin-top: 30px;
- padding: 20px;
- background: #fff;
- border: 1px solid #ddd;
- border-radius: 4px;
- }
-
- .import-export-section h2 {
- margin-top: 0;
- color: var(--prpl-color-headings);
- }
-
- .import-export-actions {
- display: flex;
- align-items: center;
- gap: 20px;
- }
-
- .import-export-actions input[type="file"] {
- margin-right: 10px;
- }
- ';
- }
-}
diff --git a/classes/utils/class-date.php b/classes/utils/class-date.php
index 42f6cba79a..2810fa8401 100644
--- a/classes/utils/class-date.php
+++ b/classes/utils/class-date.php
@@ -18,10 +18,7 @@ class Date {
* @param \DateTime $start_date The start date.
* @param \DateTime $end_date The end date.
*
- * @return array [
- * 'start_date' => \DateTime,
- * 'end_date' => \DateTime,
- * ].
+ * @return array
>
*/
public function get_range( $start_date, $end_date ) {
$dates = \iterator_to_array( new \DatePeriod( $start_date, new \DateInterval( 'P1D' ), $end_date ), false );
@@ -38,7 +35,7 @@ public function get_range( $start_date, $end_date ) {
* @param \DateTime $end_date The end date.
* @param string $frequency The frequency. Can be 'daily', 'weekly', 'monthly'.
*
- * @return array
+ * @return array
*/
public function get_periods( $start_date, $end_date, $frequency ) {
$end_date->modify( '+1 day' );
@@ -71,8 +68,15 @@ public function get_periods( $start_date, $end_date, $frequency ) {
if ( empty( $date_ranges ) ) {
return [];
}
- if ( $end_date->format( 'z' ) !== \end( $date_ranges )['end_date']->format( 'z' ) ) {
- $final_end = clone \end( $date_ranges )['end_date'];
+ $last_range = \end( $date_ranges );
+ /**
+ * The end date of the last range.
+ *
+ * @var \DateTime $last_end_date
+ */
+ $last_end_date = $last_range['end_date'];
+ if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) {
+ $final_end = clone $last_end_date;
$date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date );
}
diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php
index 9a416d9497..6b42a5ca69 100644
--- a/classes/utils/class-debug-tools.php
+++ b/classes/utils/class-debug-tools.php
@@ -48,13 +48,11 @@ public function __construct() {
\add_action( 'init', [ $this, 'check_toggle_migrations' ] );
\add_action( 'init', [ $this, 'check_delete_single_task' ] );
\add_action( 'init', [ $this, 'check_toggle_recommendations_ui' ] );
+ \add_action( 'init', [ $this, 'check_delete_onboarding_progress' ] );
if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) {
\add_action( 'init', [ $this, 'check_toggle_placeholder_demo' ] );
}
- // Initialize color customizer.
- $this->get_color_customizer();
-
\add_filter( 'progress_planner_tasks_show_ui', [ $this, 'filter_tasks_show_ui' ] );
}
@@ -101,17 +99,9 @@ public function add_toolbar_items( $admin_bar ) {
$this->add_toggle_recommendations_ui_submenu_item( $admin_bar );
- // Add color customizer item.
- $admin_bar->add_node(
- [
- 'id' => 'prpl-color-customizer',
- 'parent' => 'prpl-debug',
- 'title' => 'Color Customizer',
- 'href' => \admin_url( 'admin.php?page=progress-planner-color-customizer' ),
- ]
- );
-
$this->add_placeholder_demo_submenu_item( $admin_bar );
+
+ $this->add_onboarding_submenu_item( $admin_bar );
}
/**
@@ -528,7 +518,7 @@ protected function add_more_info_submenu_item( $admin_bar ) {
);
// Free license info.
- $prpl_free_license_key = \get_option( 'progress_planner_license_key', false );
+ $prpl_free_license_key = \progress_planner()->get_license_key();
$admin_bar->add_node(
[
'id' => 'prpl-free-license',
@@ -728,19 +718,6 @@ public function check_toggle_placeholder_demo() {
exit;
}
- /**
- * Get color customizer instance.
- *
- * @return \Progress_Planner\Utils\Color_Customizer
- */
- public function get_color_customizer() {
- static $color_customizer = null;
- if ( null === $color_customizer ) {
- $color_customizer = new Color_Customizer();
- }
- return $color_customizer;
- }
-
/**
* Filter the tasks show UI.
*
@@ -753,4 +730,74 @@ public function filter_tasks_show_ui( $show_ui ) {
}
return $show_ui;
}
+
+ /**
+ * Add Onboarding submenu to the debug menu.
+ *
+ * @param \WP_Admin_Bar $admin_bar The WordPress admin bar object.
+ * @return void
+ */
+ protected function add_onboarding_submenu_item( $admin_bar ) {
+ $admin_bar->add_node(
+ [
+ 'id' => 'prpl-onboarding',
+ 'parent' => 'prpl-debug',
+ 'title' => 'Onboarding',
+ ]
+ );
+
+ // Start onboarding.
+ $admin_bar->add_node(
+ [
+ 'id' => 'prpl-start-onboarding',
+ 'parent' => 'prpl-onboarding',
+ 'title' => 'Start Onboarding',
+ 'href' => '#',
+ 'meta' => [
+ 'onclick' => 'window.prplOnboardWizard.startOnboarding(); return false;',
+ ],
+ ]
+ );
+
+ // Delete onboarding progress.
+ $admin_bar->add_node(
+ [
+ 'id' => 'prpl-delete-onboarding-progress',
+ 'parent' => 'prpl-onboarding',
+ 'title' => 'Delete Onboarding Progress',
+ 'href' => \add_query_arg( 'prpl_delete_onboarding_progress', '1', $this->current_url ),
+ ]
+ );
+ }
+
+ /**
+ * Check and process the delete onboarding progress action.
+ *
+ * Deletes onboarding progress if the appropriate query parameter is set
+ * and user has required capabilities.
+ *
+ * @return void
+ */
+ public function check_delete_onboarding_progress() {
+ if (
+ ! isset( $_GET['prpl_delete_onboarding_progress'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $_GET['prpl_delete_onboarding_progress'] !== '1' || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ ! \current_user_can( 'manage_options' )
+ ) {
+ return;
+ }
+
+ // Verify nonce for security.
+ $this->verify_nonce();
+
+ // Delete the onboarding progress.
+ \Progress_Planner\Onboard_Wizard::delete_progress();
+
+ // Delete the license key.
+ \delete_option( 'progress_planner_license_key' );
+
+ // Redirect to the same page without the parameter.
+ \wp_safe_redirect( \remove_query_arg( [ 'prpl_delete_onboarding_progress', '_wpnonce' ] ) );
+ exit;
+ }
}
diff --git a/classes/utils/class-deprecations.php b/classes/utils/class-deprecations.php
index b072229003..656ce4dc29 100644
--- a/classes/utils/class-deprecations.php
+++ b/classes/utils/class-deprecations.php
@@ -81,7 +81,6 @@ class Deprecations {
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Sample_Page' => [ 'Progress_Planner\Suggested_Tasks\Providers\Sample_Page', '1.4.0' ],
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Search_Engine_Visibility' => [ 'Progress_Planner\Suggested_Tasks\Providers\Search_Engine_Visibility', '1.4.0' ],
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Set_Valuable_Post_Types' => [ 'Progress_Planner\Suggested_Tasks\Providers\Set_Valuable_Post_Types', '1.4.0' ],
- 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Settings_Saved' => [ 'Progress_Planner\Suggested_Tasks\Providers\Settings_Saved', '1.4.0' ],
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Site_Icon' => [ 'Progress_Planner\Suggested_Tasks\Providers\Site_Icon', '1.4.0' ],
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Core_Update' => [ 'Progress_Planner\Suggested_Tasks\Providers\Core_Update', '1.4.0' ],
'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Create' => [ 'Progress_Planner\Suggested_Tasks\Providers\Repetitive\Create', '1.4.0' ],
diff --git a/classes/utils/class-onboard.php b/classes/utils/class-onboard.php
index 25ab718902..37655789da 100644
--- a/classes/utils/class-onboard.php
+++ b/classes/utils/class-onboard.php
@@ -29,7 +29,7 @@ public function __construct() {
// Detect domain changes.
\add_action( 'shutdown', [ $this, 'detect_site_url_changes' ] );
- if ( \get_option( 'progress_planner_license_key' ) ) {
+ if ( \progress_planner()->get_license_key() ) {
return;
}
@@ -194,7 +194,7 @@ public function detect_site_url_changes() {
return;
}
- $saved_license_key = \get_option( 'progress_planner_license_key', false );
+ $saved_license_key = \progress_planner()->get_license_key();
// Bail early if the license key is not set, or if the site URL has not changed.
if ( ! $saved_license_key || $saved_site_url === $current_site_url ) {
diff --git a/classes/utils/class-playground.php b/classes/utils/class-playground.php
index f201cde65b..130a004315 100644
--- a/classes/utils/class-playground.php
+++ b/classes/utils/class-playground.php
@@ -28,10 +28,9 @@ public function __construct() {
* @return void
*/
public function register_hooks() {
- if ( ! \get_option( 'progress_planner_license_key', false ) && ! \get_option( 'progress_planner_demo_data_generated', false ) ) {
+ if ( ! \progress_planner()->get_license_key() && ! \get_option( 'progress_planner_demo_data_generated', false ) ) {
$this->generate_data();
\update_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) );
- \update_option( 'progress_planner_force_show_onboarding', false );
\update_option(
'progress_planner_todo',
[
@@ -48,7 +47,6 @@ public function register_hooks() {
\update_option( 'progress_planner_demo_data_generated', true );
}
\add_action( 'progress_planner_admin_page_header_before', [ $this, 'show_header_notice' ] );
- \add_action( 'wp_ajax_progress_planner_hide_onboarding', [ $this, 'hide_onboarding' ] );
\add_action( 'wp_ajax_progress_planner_show_onboarding', [ $this, 'show_onboarding' ] );
\progress_planner()->get_settings()->set( 'activation_date', ( new \DateTime() )->modify( '-2 months' )->format( 'Y-m-d' ) );
@@ -80,48 +78,23 @@ public function enable_debug_tools() {
}
/**
- * Toggle the onboarding visibility in the Playground environment.
- *
- * @param string $action Either 'show' or 'hide'.
+ * Show the onboarding in the Playground environment.
*
* @return void
*/
- private function toggle_onboarding( $action ) {
- $nonce_action = "progress_planner_{$action}_onboarding";
- \check_ajax_referer( $nonce_action, 'nonce' );
+ public function show_onboarding() {
+ \check_ajax_referer( 'progress_planner_show_onboarding', 'nonce' );
if ( ! \current_user_can( 'manage_options' ) ) {
\wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'progress-planner' ) );
}
- if ( $action === 'hide' ) {
- \add_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) );
- $message = \esc_html__( 'Onboarding hidden successfully', 'progress-planner' );
- } else {
- \delete_option( 'progress_planner_license_key' );
- $message = \esc_html__( 'Onboarding shown successfully', 'progress-planner' );
- }
- \update_option( 'progress_planner_force_show_onboarding', $action !== 'hide' );
-
- \wp_send_json_success( [ 'message' => $message ] );
- }
-
- /**
- * Hide the onboarding in the Playground environment.
- *
- * @return void
- */
- public function hide_onboarding() {
- $this->toggle_onboarding( 'hide' );
- }
+ // Delete onboarding progress to trigger fresh onboarding.
+ \Progress_Planner\Onboard_Wizard::delete_progress();
+ // Delete the license key to trigger onboarding (privacy not accepted).
+ \delete_option( 'progress_planner_license_key' );
- /**
- * Show the onboarding in the Playground environment.
- *
- * @return void
- */
- public function show_onboarding() {
- $this->toggle_onboarding( 'show' );
+ \wp_send_json_success( [ 'message' => \esc_html__( 'Onboarding shown successfully', 'progress-planner' ) ] );
}
/**
@@ -135,10 +108,7 @@ public function show_header_notice() {
return;
}
- $show_onboarding = \get_option( 'progress_planner_force_show_onboarding', false );
- $button_text = $show_onboarding ? \__( 'Hide onboarding', 'progress-planner' ) : \__( 'Show onboarding', 'progress-planner' );
- $action = $show_onboarding ? 'hide' : 'show';
- $nonce = \wp_create_nonce( "progress_planner_{$action}_onboarding" );
+ $nonce = \wp_create_nonce( 'progress_planner_show_onboarding' );
?>