diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..5f4d48c7d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,27 @@ +# CodeRabbit Configuration +# https://docs.coderabbit.ai/guides/configure-coderabbit + +# Review configuration +reviews: + # Enable reviews for PRs targeting develop branch (not just default branch) + review_status: true + + # Auto-review PRs targeting these branches + auto_review: + enabled: true + base_branches: + - develop + # Auto-incremental reviews when new commits pushed + auto_incremental_review: true + + # Collapse file walkthrough in PR comments + collapse_walkthrough: false + + # High-level summary at top of review + high_level_summary: true + + # Add poem to reviews + poem: false + +# Review language +language: en-US diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..548ca6881 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,400 @@ +name: CI - Code Quality + +on: + pull_request: + workflow_dispatch: + +env: + WP_VERSION: 6.1.1 + +# 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: + # ============================================================================ + # Change Detection - Fast first step to gate expensive jobs + # ============================================================================ + # + # tj-actions/changed-files OUTPUT NAMING: + # ───────────────────────────────────────────────────────────────────────────── + # This job uses `files_yaml:` to define multiple file categories. + # With files_yaml, outputs are prefixed with the category name: + # - php_any_changed, js_any_changed, css_any_changed, etc. + # + # Individual jobs below use `files:` (single category) which outputs: + # - any_changed (no prefix) + # + # Both patterns are correct for their respective configurations. + # ───────────────────────────────────────────────────────────────────────────── + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + # NOTE: With files_yaml:, outputs are {category}_any_changed + outputs: + php: ${{ steps.changes.outputs.php_any_changed }} + js: ${{ steps.changes.outputs.js_any_changed }} # All JS/TS (for ESLint) + js_src: ${{ steps.changes.outputs.js_src_any_changed }} # All source JS/TS (for build) + ts_src: ${{ steps.changes.outputs.ts_src_any_changed }} # TypeScript only (for tsc) + css: ${{ steps.changes.outputs.css_any_changed }} + npm_deps: ${{ steps.changes.outputs.npm_deps_any_changed }} + php_deps: ${{ steps.changes.outputs.php_deps_any_changed }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Detect file changes + id: changes + uses: tj-actions/changed-files@v41 + with: + # files_yaml: defines multiple categories → outputs are {category}_any_changed + files_yaml: | + php: + - '**/*.php' + - '!vendor/**' + - '!vendor-prefixed/**' + js: + # All JS/TS for ESLint (config, tooling, source, etc.) + - '**/*.js' + - '**/*.ts' + - '**/*.tsx' + - '**/*.jsx' + - '!node_modules/**' + - '!dist/**' + - '!build/**' + - '!vendor/**' + js_src: + # All source JS/TS files (for build validation) + - 'packages/**/src/**/*.ts' + - 'packages/**/src/**/*.tsx' + - 'packages/**/src/**/*.js' + - 'assets/js/src/**/*.js' + ts_src: + # TypeScript only (for tsc type checking) + - 'packages/**/src/**/*.ts' + - 'packages/**/src/**/*.tsx' + css: + # Only source CSS/SCSS (not built output) + - 'assets/css/src/**/*.css' + - 'assets/css/src/**/*.scss' + - 'packages/**/src/**/*.css' + - 'packages/**/src/**/*.scss' + npm_deps: + - 'package.json' + - 'package-lock.json' + php_deps: + - 'composer.json' + - 'composer.lock' + + # ============================================================================ + # PHP Code Quality - PHPCS & PHPStan + # ============================================================================ + php-quality: + name: PHP ${{ matrix.php-version }} - Code Quality + runs-on: ubuntu-latest + needs: detect-changes + if: ${{ github.event_name == 'pull_request' && needs.detect-changes.outputs.php == 'true' }} + + strategy: + fail-fast: false + matrix: + # PHPCS only on 8.2 - PHPUnit dev dep uses PHP 8+ syntax in vendor autoload + # Code compatibility is the same regardless of PHP version running PHPCS + php-version: ['8.2'] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # NOTE: Using `files:` (not files_yaml) → outputs are just `any_changed` + - name: Get changed PHP files + id: changed-files + uses: tj-actions/changed-files@v41 + with: + files: | + **/*.php + files_ignore: | + vendor/** + vendor-prefixed/** + tests/** + node_modules/** + + - name: Setup cache extensions + if: steps.changed-files.outputs.any_changed == 'true' + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: 'curl, zip' + key: php-${{ matrix.php-version }}-ext + + - name: Cache extensions + if: steps.changed-files.outputs.any_changed == 'true' + uses: actions/cache@v5 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + + - name: Setup PHP + if: steps.changed-files.outputs.any_changed == 'true' + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, zip + tools: composer, cs2pr, phpcs + coverage: none + + - name: Install composer dependencies + if: steps.changed-files.outputs.any_changed == 'true' + run: composer install --no-interaction --optimize-autoloader --ignore-platform-reqs + + - name: Run PHPCS on changed files + if: steps.changed-files.outputs.any_changed == 'true' + id: phpcs + continue-on-error: true + run: | + echo "${{ steps.changed-files.outputs.all_changed_files }}" | tr ' ' '\n' > .changed-files.txt + # --warning-severity=0: Only fail on errors, not warnings (warnings still shown) + vendor/bin/phpcs --standard=.phpcs.xml.dist --warning-severity=0 --report-full --report-checkstyle=./phpcs-report.xml --file-list=.changed-files.txt + + - name: Show PHPCS results in PR + if: steps.changed-files.outputs.any_changed == 'true' && always() && steps.phpcs.outcome == 'failure' + run: cs2pr ./phpcs-report.xml + + - name: Run PHPStan + if: steps.changed-files.outputs.any_changed == 'true' && matrix.php-version == '8.2' + continue-on-error: true + run: composer phpstan + + - name: Check for PHPCS failures + if: steps.changed-files.outputs.any_changed == 'true' && steps.phpcs.outcome == 'failure' + run: exit 1 + + # ============================================================================ + # JavaScript/TypeScript Code Quality + # ============================================================================ + js-quality: + name: JS/TS Code Quality + runs-on: ubuntu-latest + needs: detect-changes + if: ${{ github.event_name == 'pull_request' && (needs.detect-changes.outputs.js == 'true' || needs.detect-changes.outputs.css == 'true') }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + # NOTE: Using `files:` (not files_yaml) → outputs are just `any_changed` + # All JS/TS files for ESLint (config, tooling, source, etc.) + - name: Get changed JS/TS files + id: changed-js + uses: tj-actions/changed-files@v41 + with: + files: | + **/*.js + **/*.ts + **/*.tsx + **/*.jsx + files_ignore: | + node_modules/** + vendor/** + dist/** + build/** + coverage/** + + # NOTE: Using `files:` (not files_yaml) → outputs are just `any_changed` + - name: Get changed CSS/SCSS files + id: changed-css + uses: tj-actions/changed-files@v41 + with: + files: | + **/*.css + **/*.scss + files_ignore: | + node_modules/** + vendor/** + dist/** + build/** + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint on changed files + if: steps.changed-js.outputs.any_changed == 'true' + continue-on-error: true + id: eslint + run: | + echo "${{ steps.changed-js.outputs.all_changed_files }}" | xargs npm run lint:js:changed -- + + - name: Run Stylelint on changed files + if: steps.changed-css.outputs.any_changed == 'true' + continue-on-error: true + id: stylelint + run: | + echo "${{ steps.changed-css.outputs.all_changed_files }}" | xargs npm run lint:style -- --formatter=compact + + # Only type-check when TypeScript files change + - name: Run TypeScript type checking + if: needs.detect-changes.outputs.ts_src == 'true' + continue-on-error: true + id: tsc + run: npm run build:tsc + + - name: Check for linting failures + if: steps.eslint.outcome == 'failure' || steps.stylelint.outcome == 'failure' || steps.tsc.outcome == 'failure' + run: | + echo "❌ Code quality checks failed:" + [[ "${{ steps.eslint.outcome }}" == "failure" ]] && echo " - ESLint found issues" + [[ "${{ steps.stylelint.outcome }}" == "failure" ]] && echo " - Stylelint found issues" + [[ "${{ steps.tsc.outcome }}" == "failure" ]] && echo " - TypeScript type errors" + exit 1 + + # ============================================================================ + # Build Validation + # ============================================================================ + build-validation: + name: Build Validation + runs-on: ubuntu-latest + needs: detect-changes + # Only build when source files change (not config/tooling JS) + if: ${{ github.event_name == 'pull_request' && (needs.detect-changes.outputs.js_src == 'true' || needs.detect-changes.outputs.npm_deps == 'true') }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Validate dependency tree + continue-on-error: true + id: dep-tree + run: npm run validate:dep-tree + + - name: Build project + id: build + run: npm run build + + - name: Check for build failures + if: steps.build.outcome == 'failure' + run: | + echo "❌ Build failed - webpack compilation errors detected" + exit 1 + + - name: Report dependency tree issues + if: steps.dep-tree.outcome == 'failure' + run: | + echo "⚠️ Dependency tree validation found issues" + echo "Run 'npm run validate:dep-tree:fix' locally to resolve" + + # ============================================================================ + # Security Scanning - NPM Dependencies + # ============================================================================ + npm-security: + name: NPM Security Audit + runs-on: ubuntu-latest + needs: detect-changes + if: ${{ github.event_name == 'pull_request' && needs.detect-changes.outputs.npm_deps == 'true' }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + + - name: Install npm dependencies + run: npm ci + + - name: Run npm audit + continue-on-error: true + run: npm audit --audit-level=high || echo "⚠️ NPM audit found vulnerabilities" + + # ============================================================================ + # Security Scanning - Composer Dependencies + # ============================================================================ + php-security: + name: Composer Security Audit + runs-on: ubuntu-latest + needs: detect-changes + if: ${{ github.event_name == 'pull_request' && needs.detect-changes.outputs.php_deps == 'true' }} + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer + + - name: Install composer dependencies + run: composer install --no-interaction --ignore-platform-reqs + + - name: Run composer audit + continue-on-error: true + run: composer audit || echo "⚠️ Composer audit found vulnerabilities" + + # ============================================================================ + # PHPUnit Tests (DISABLED - Enable when test suite is comprehensive) + # ============================================================================ + phpunit: + name: PHPUnit - PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + if: false # Disabled until test suite is more comprehensive + + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2'] + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ROOT_PASSWORD: password + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, mysql, zip + coverage: xdebug + + - name: Install composer dependencies + run: composer install --prefer-dist --no-progress --ignore-platform-reqs + + - name: Install WordPress test environment + run: bash bin/install-wp-tests.sh wordpress_test root 'password' mysql + + - name: Run PHPUnit tests + run: composer tests diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 000000000..ac038f9c5 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,114 @@ +name: Commitlint + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [develop] + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Fetch all history for commit analysis + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check for AI attribution + run: | + # Check commits for AI attribution + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + COMMITS=$(git log --format=%H ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}) + else + COMMITS=$(git log --format=%H HEAD~1..HEAD) + fi + + AI_FOUND=false + for commit in $COMMITS; do + if git log --format=%B -n 1 $commit | grep -qE "(Co-Authored-By: Claude|Generated with.*Claude Code|🤖 Generated with)"; then + echo "❌ AI attribution found in commit: $commit" + echo " Message:" + git log --format=%B -n 1 $commit | head -5 + echo "" + AI_FOUND=true + fi + done + + if [ "$AI_FOUND" = true ]; then + echo "" + echo "❌ Commits rejected: AI attribution detected" + echo "" + echo "Please remove AI attribution lines like:" + echo " - Co-Authored-By: Claude " + echo " - 🤖 Generated with [Claude Code](https://claude.ai/code)" + exit 1 + fi + + - name: Validate commits + run: | + # For PRs, check all commits in the PR + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + else + # For pushes, check the last commit + npx commitlint --from HEAD~1 --to HEAD --verbose + fi + + - name: Post helpful message on failure + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + // Check for existing commitlint error comment + const comments = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo + }); + + const marker = '## ❌ Commit Validation Error'; + const existingComment = comments.data.find(comment => + comment.body.includes(marker) + ); + + // Only post if no existing comment found + if (!existingComment) { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## ❌ Commit Validation Error + + Your commits don't pass validation. Common issues: + + ### Format Requirements + Commits must follow conventional commit format: + + \`\`\` + type(scope): subject + + [optional body] + + [optional footer] + \`\`\` + + **Valid types:** feat, fix, improve, perf, refactor, docs, style, test, build, ci, chore, revert + **Valid scopes:** admin, conditions, cookies, frontend, popup, theme, triggers, forms, extensions, integrations, accessibility, performance, ui, ux, build, deps, tests, api, core, docs, release, support + + **Examples:** + - \`feat(triggers): add exit intent detection\` + - \`improve(popup): enhance animation speed options\` + - \`fix(forms): resolve Contact Form 7 tracking issue\` + + See: https://www.conventionalcommits.org/` + }); + } diff --git a/.github/workflows/notify-support.yml b/.github/workflows/notify-support.yml new file mode 100644 index 000000000..768ee120f --- /dev/null +++ b/.github/workflows/notify-support.yml @@ -0,0 +1,203 @@ +name: Notify Support Team + +on: + release: + types: [published] + + # Manual trigger for testing + workflow_dispatch: + inputs: + version: + description: 'Version to test (e.g., 1.22.0)' + required: true + default: '1.22.0-test' + release_body: + description: 'Simulated release body/changelog' + required: false + default: | + - Fixed popup display issue on mobile devices + - Improved animation performance + - Added new trigger: Scroll Depth + prerelease: + description: 'Simulate pre-release (alpha/beta/rc)?' + type: boolean + default: false + dry_run: + description: 'Dry run - output payload without sending to Slack' + type: boolean + default: true + +jobs: + notify-support: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set variables + id: vars + run: | + # Use release event or workflow_dispatch inputs + if [ "${{ github.event_name }}" = "release" ]; then + echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + echo "release_url=${{ github.event.release.html_url }}" >> $GITHUB_OUTPUT + echo "zip_url=${{ github.event.release.zipball_url }}" >> $GITHUB_OUTPUT + echo "dry_run=false" >> $GITHUB_OUTPUT + + # Store release body for changelog extraction + cat << 'EOFBODY' > /tmp/release_body.txt + ${{ github.event.release.body }} + EOFBODY + else + VERSION="${{ inputs.version }}" + # Add prerelease suffix if checked + if [ "${{ inputs.prerelease }}" = "true" ]; then + VERSION="${VERSION}-beta.1" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "release_url=${{ github.server_url }}/${{ github.repository }}/releases/tag/$VERSION" >> $GITHUB_OUTPUT + echo "zip_url=${{ github.server_url }}/${{ github.repository }}/archive/refs/tags/$VERSION.zip" >> $GITHUB_OUTPUT + echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT + + # Use input body + cat << 'EOFBODY' > /tmp/release_body.txt + ${{ inputs.release_body }} + EOFBODY + fi + + - name: Extract customer-facing changelog + id: changelog + run: | + BODY=$(cat /tmp/release_body.txt) + + # Filter to customer-facing changes only (bullet points) + # Remove technical details, keep user-facing items + # Using [[:space:]] for POSIX compatibility + CHANGELOG=$(echo "$BODY" | grep -E "^[-*•]|^[[:space:]]+[-*•]" | head -10) + + # If no bullet points found, try to extract from body + if [ -z "$CHANGELOG" ]; then + CHANGELOG=$(echo "$BODY" | head -10) + fi + + # Truncate to 800 chars (UTF-8 safe using cut instead of head -c) + CHANGELOG=$(echo "$CHANGELOG" | cut -c 1-800) + + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Determine release type + id: release_type + run: | + TAG="${{ steps.vars.outputs.version }}" + + # Check for pre-release indicators + if [[ "$TAG" =~ -alpha || "$TAG" =~ -beta || "$TAG" =~ -rc ]]; then + echo "type=Pre-release" >> $GITHUB_OUTPUT + echo "emoji=🧪" >> $GITHUB_OUTPUT + else + echo "type=Stable" >> $GITHUB_OUTPUT + echo "emoji=🚀" >> $GITHUB_OUTPUT + fi + + - name: Build Slack payload + id: payload + run: | + PLUGIN="${{ github.event.repository.name }}" + VERSION="${{ steps.vars.outputs.version }}" + RELEASE_URL="${{ steps.vars.outputs.release_url }}" + ZIP_URL="${{ steps.vars.outputs.zip_url }}" + RELEASE_TYPE="${{ steps.release_type.outputs.type }}" + EMOJI="${{ steps.release_type.outputs.emoji }}" + CHANGELOG='${{ steps.changelog.outputs.changelog }}' + + # Escape changelog for JSON + CHANGELOG_ESCAPED=$(echo "$CHANGELOG" | jq -Rs . | sed 's/^"//;s/"$//') + + PAYLOAD=$(cat << EOFPAYLOAD + { + "text": "${EMOJI} ${PLUGIN} ${VERSION} Released", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "${EMOJI} ${PLUGIN} ${VERSION} Released", + "emoji": true + } + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Release Type:*\n${RELEASE_TYPE}"}, + {"type": "mrkdwn", "text": "*Version:*\n${VERSION}"} + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*What's New for Customers:*\n${CHANGELOG_ESCAPED}" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Support Team Actions:*\n• Review changelog for common customer questions\n• Update support docs if needed\n• Monitor HelpScout for related tickets" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "📖 Full Changelog", "emoji": true}, + "url": "${RELEASE_URL}" + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "📥 Download ZIP", "emoji": true}, + "url": "${ZIP_URL}" + } + ] + } + ] + } + EOFPAYLOAD + ) + + # Store payload for use in next steps + echo "payload<> $GITHUB_OUTPUT + echo "$PAYLOAD" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Dry run - Show payload + if: steps.vars.outputs.dry_run == 'true' + run: | + echo "🧪 DRY RUN MODE - Would send this payload to Slack:" + echo "" + echo "========== SLACK PAYLOAD ==========" + echo '${{ steps.payload.outputs.payload }}' | jq . + echo "===================================" + echo "" + echo "📊 Release Type: ${{ steps.release_type.outputs.type }}" + echo "🏷️ Version: ${{ steps.vars.outputs.version }}" + echo "📦 ZIP URL: ${{ steps.vars.outputs.zip_url }}" + echo "" + echo "✅ Dry run complete. No message sent to Slack." + + - name: Send to #support-updates + if: steps.vars.outputs.dry_run != 'true' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_SUPPORT }} + run: | + echo "📤 Sending support notification to Slack..." + curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d '${{ steps.payload.outputs.payload }}' + echo "" + echo "✅ Slack message sent to #support-updates!" diff --git a/.github/workflows/phpcs-tests.yml b/.github/workflows/phpcs-tests.yml.disabled similarity index 100% rename from .github/workflows/phpcs-tests.yml rename to .github/workflows/phpcs-tests.yml.disabled diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml.disabled similarity index 100% rename from .github/workflows/phpunit-tests.yml rename to .github/workflows/phpunit-tests.yml.disabled diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..9fd429785 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,76 @@ +name: Release Please + +on: + # Manual trigger for testing + workflow_dispatch: + + # Weekly schedule (Monday 9am UTC) - Primary trigger + # Aggregates all commits since last release into a single PR + schedule: + - cron: '0 9 * * 1' + + # NOTE: No push trigger - releases are weekly rollups, not per-commit + # For emergency patches, use: npm run prepare-release:patch -- --auto + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + # Only construct version when release is created to avoid malformed ".." strings + version: ${{ steps.release.outputs.release_created && format('{0}.{1}.{2}', steps.release.outputs.major, steps.release.outputs.minor, steps.release.outputs.patch) || '' }} + pr: ${{ steps.release.outputs.pr }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Release Please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Show Release Please outputs + run: | + echo "Release created: ${{ steps.release.outputs.release_created }}" + echo "Tag: ${{ steps.release.outputs.tag_name }}" + echo "PR: ${{ steps.release.outputs.pr }}" + echo "All outputs:" + echo '${{ toJSON(steps.release.outputs) }}' + + # When a release is created, update version in all plugin files + # Uses the battle-tested update-versions.js script + - name: Setup Node.js + if: ${{ steps.release.outputs.release_created }} + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + if: ${{ steps.release.outputs.release_created }} + run: npm ci + + - name: Update version in plugin files + if: ${{ steps.release.outputs.release_created }} + run: | + VERSION="${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" + echo "Updating all plugin files to version $VERSION" + npm run version:update -- "$VERSION" + + - name: Commit version updates + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git diff --staged --quiet || git commit -m "chore(release): update version files to ${{ steps.release.outputs.tag_name }}" + git push diff --git a/.github/workflows/request-approval.yml b/.github/workflows/request-approval.yml new file mode 100644 index 000000000..73a46b89d --- /dev/null +++ b/.github/workflows/request-approval.yml @@ -0,0 +1,195 @@ +name: Request Release Approval + +on: + repository_dispatch: + types: [request-approval] + + # Manual trigger for testing + workflow_dispatch: + inputs: + version: + description: 'Version to test (e.g., 1.22.0)' + required: true + default: '1.22.0-test' + plugin: + description: 'Plugin name' + required: true + default: 'popup-maker' + dry_run: + description: 'Dry run - output payload without sending to Slack' + type: boolean + default: true + +jobs: + send-approval-request: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set variables + id: vars + run: | + # Use dispatch payload or workflow_dispatch inputs + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT + echo "plugin=${{ github.event.client_payload.plugin || github.event.repository.name }}" >> $GITHUB_OUTPUT + echo "artifact=${{ github.event.client_payload.artifact }}" >> $GITHUB_OUTPUT + echo "dry_run=false" >> $GITHUB_OUTPUT + else + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + echo "plugin=${{ inputs.plugin }}" >> $GITHUB_OUTPUT + echo "artifact=test-artifact-${{ inputs.version }}" >> $GITHUB_OUTPUT + echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT + fi + + - name: Extract changelog for version + id: changelog + run: | + VERSION="${{ steps.vars.outputs.version }}" + + # Try multiple changelog extraction methods for robustness + CHANGELOG="" + + # Method 1: Extract from changelog.txt (WordPress style) + # Pattern matches "= X.Y.Z" format with proper version boundaries + if [ -f "changelog.txt" ]; then + CHANGELOG=$(sed -n "/^= $VERSION/,/^= [0-9][0-9.]*[[:space:]]/p" changelog.txt | head -n -1 | tail -n +2) + fi + + # Method 2: Extract from CHANGELOG.md if changelog.txt failed + # Pattern matches "## [X.Y.Z]" format + if [ -z "$CHANGELOG" ] && [ -f "CHANGELOG.md" ]; then + CHANGELOG=$(sed -n "/^## \[$VERSION\]/,/^## \[[0-9]/p" CHANGELOG.md | head -n -1 | tail -n +2) + fi + + # Method 3: Fallback to readme.txt + if [ -z "$CHANGELOG" ] && [ -f "readme.txt" ]; then + CHANGELOG=$(sed -n "/^= $VERSION/,/^= [0-9][0-9.]*[[:space:]]/p" readme.txt | head -n -1 | tail -n +2) + fi + + # Fallback for testing if no changelog found + if [ -z "$CHANGELOG" ]; then + CHANGELOG="• No changelog found for version $VERSION\n• This is a test run" + fi + + # Truncate to 500 chars (UTF-8 safe) + CHANGELOG=$(echo "$CHANGELOG" | cut -c 1-500) + + # Escape for JSON + CHANGELOG=$(echo "$CHANGELOG" | jq -Rs .) + + echo "changelog=$CHANGELOG" >> $GITHUB_OUTPUT + + - name: Build Slack payload + id: payload + run: | + VERSION="${{ steps.vars.outputs.version }}" + PLUGIN="${{ steps.vars.outputs.plugin }}" + ARTIFACT="${{ steps.vars.outputs.artifact }}" + REPO="${{ github.event.repository.name }}" + DOWNLOAD_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + CHANGELOG=${{ steps.changelog.outputs.changelog }} + DRY_RUN="${{ steps.vars.outputs.dry_run }}" + + # Build action value JSON and stringify for Slack button value + ACTION_VALUE=$(jq -nc \ + --arg repo "$REPO" \ + --arg version "$VERSION" \ + --arg artifact "$ARTIFACT" \ + --arg plugin "$PLUGIN" \ + '{repo: $repo, version: $version, artifact: $artifact, plugin: $plugin} | @json') + + # Remove outer quotes added by @json for shell interpolation + ACTION_VALUE=$(echo "$ACTION_VALUE" | sed 's/^"//;s/"$//') + + # Build the full payload + PAYLOAD=$(cat << EOFPAYLOAD + { + "text": "🔔 Release Approval Required: ${PLUGIN} v${VERSION}", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🔔 Release Approval Required", + "emoji": true + } + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Plugin:*\n${PLUGIN}"}, + {"type": "mrkdwn", "text": "*Version:*\n${VERSION}"} + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Changelog Preview:*\n${CHANGELOG:1:-1}" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "📦 View Build", "emoji": true}, + "url": "${DOWNLOAD_URL}" + } + ] + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Approve & Deploy", "emoji": true}, + "style": "primary", + "action_id": "approve_release", + "value": "${ACTION_VALUE}" + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "❌ Reject", "emoji": true}, + "style": "danger", + "action_id": "reject_release", + "value": "${ACTION_VALUE}" + } + ] + } + ] + } + EOFPAYLOAD + ) + + # Store payload for use in next steps + echo "payload<> $GITHUB_OUTPUT + echo "$PAYLOAD" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Dry run - Show payload + if: steps.vars.outputs.dry_run == 'true' + run: | + echo "🧪 DRY RUN MODE - Would send this payload to Slack:" + echo "" + echo "========== SLACK PAYLOAD ==========" + echo '${{ steps.payload.outputs.payload }}' | jq . + echo "===================================" + echo "" + echo "✅ Dry run complete. No message sent to Slack." + + - name: Send Slack approval request + if: steps.vars.outputs.dry_run != 'true' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_DEV }} + run: | + echo "📤 Sending approval request to Slack..." + curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d '${{ steps.payload.outputs.payload }}' + echo "" + echo "✅ Slack message sent!" diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..5ab108e01 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,17 @@ +#!/usr/bin/env sh + +# Reject commits with AI attribution +COMMIT_MSG_FILE="$1" + +if grep -qE "(Co-Authored-By: Claude|Generated with.*Claude Code|🤖 Generated with)" "$COMMIT_MSG_FILE"; then + echo "" + echo "❌ Commit rejected: AI attribution detected" + echo "" + echo "Please remove AI attribution lines like:" + echo " - Co-Authored-By: Claude " + echo " - 🤖 Generated with [Claude Code](https://claude.ai/code)" + echo "" + exit 1 +fi + +npx --no -- commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..4a6f519d1 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +# Pre-commit hook placeholder +# Tests will be added in a future phase +# For now, commitlint (commit-msg hook) handles commit validation diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 000000000..e3cb294d1 --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false, + "packages": { + ".": { + "component": "popup-maker", + "changelog-path": "changelog.txt", + "changelog-type": "wordpress" + } + } +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..98fb2bf19 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.21.5" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 777ea6114..3141f7941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +**Features** + +- Added link click conversion tracking for external and special links (mailto:, tel:, etc.) within popups. Clicks are tracked via analytics beacon and categorized by link type for conversion reporting. +- Added [Beaver Builder Forms integration](https://wppopupmaker.com/form-integrations/beaver-builder/) for form submission tracking and conversion analytics. Supports Contact, Subscribe, and Login form modules. +- Added [Bit Form integration](https://wppopupmaker.com/form-integrations/bit-form/) for form submission tracking and conversion analytics. +- Added [Elementor Pro Forms integration](https://wppopupmaker.com/form-integrations/elementor-forms/) for form submission tracking and conversion analytics with support for targeting specific forms. +- Added [Forminator integration](https://wppopupmaker.com/form-integrations/forminator/) for form submission tracking and conversion analytics. +- Added [HappyForms integration](https://wppopupmaker.com/form-integrations/happyforms/) for form submission tracking and conversion analytics. +- Added [HTML Forms integration](https://wppopupmaker.com/form-integrations/html-forms/) for lightweight form submission tracking and conversion analytics. +- Added [Kali Forms integration](https://wppopupmaker.com/form-integrations/kali-forms/) for form submission tracking and conversion analytics with native Gutenberg block support. +- Added Newsletter plugin (thenewsletterplugin.com) form integration for success detection and conversion tracking. **Note:** Newsletter forms must use `[newsletter_form ajax="true"]` shortcode to enable AJAX submission mode for the integration to work. + +**Improvements** + +- Improved PID tracking reliability by firing template_redirect at priority 0, ensuring tracking occurs before other plugins that might redirect. +- Enhanced all Popup list views with sortable Enabled column and bulk enable/disable actions for easier management of multiple popups. +- Block library assets (CSS) loading unnecessarily on all front-end pages. WordPress now automatically loads these styles only when Popup Maker blocks are actually rendered. +- Enhanced ad-blocker bypass feature to obfuscate script and style element IDs (in addition to filenames) for improved bypass reliability. IDs now consistently use per site settings using either MD5 hashing or custom prefixes. + +**Fixes** + +- Fixed mailto: and tel: links inside popups being incorrectly modified with tracking parameters, which broke email and phone links. +- Fixed Fluent Forms integration fatal error when using double opt-in. Closes #1094. +- Fixed Time Delay trigger settings tab displaying blank when switching from Click Trigger advanced tab. Closes #1109. +- Fixed trigger modal "Add" button label not displaying due to incorrect i18n function usage. Props to @DAnn2012. +- Fixed Divi 4 block editor compatibility issue where the popup editor would fail to load when the block editor was enabled. The classic editor is now automatically enforced for Divi 4 users. +- Fixed license key not deactivating properly. +- Fixed issue where license keys were being saved as asterisks instead of the actual key. +- Fixed issue where filter `replace_editor` was being used as an action without returning the value. +- Fixed Subscribers page sort columns throwing 404 errors due to malformed URLs with double protocols (e.g., `https://http//site.com`). Closes #1092. +- Fixed SQL syntax error on Subscribers page caused by using identifier placeholder instead of string placeholder in `SHOW TABLES LIKE` query. + ## v1.21.5 - 2025-10-13 **Improvements** diff --git a/assets/js/src/admin/general/plugins/tabs.js b/assets/js/src/admin/general/plugins/tabs.js index dc9b839bd..0add98fe3 100644 --- a/assets/js/src/admin/general/plugins/tabs.js +++ b/assets/js/src/admin/general/plugins/tabs.js @@ -29,10 +29,15 @@ : $this.parents( '[id]' ).attr( 'id' ); if ( typeof storage[ id ] !== 'undefined' ) { - // If we have a stored tab, set it as the first tab. - $firstTab = $tabList + // If we have a stored tab, check if it exists for this trigger type. + var $storedTab = $tabList .find( 'a[href="' + storage[ id ] + '"]' ) .parent(); + + // Only use stored tab if it exists, otherwise fall back to first tab. + if ( $storedTab.length > 0 ) { + $firstTab = $storedTab; + } } if ( $this.hasClass( 'vertical-tabs' ) ) { diff --git a/assets/js/src/integration/beaverbuilder.js b/assets/js/src/integration/beaverbuilder.js new file mode 100644 index 000000000..09b9c2f53 --- /dev/null +++ b/assets/js/src/integration/beaverbuilder.js @@ -0,0 +1,80 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ + +{ + const formProvider = 'beaverbuilder'; + const $ = window.jQuery; + + // Hook into jQuery AJAX complete for all Beaver Builder forms. + $( document ).on( 'ajaxComplete', function ( _event, xhr, settings ) { + // Check if this is a Beaver Builder form submission. + if ( + ! settings.data || + ( settings.data.indexOf( 'action=fl_builder_email' ) === -1 && + settings.data.indexOf( + 'action=fl_builder_subscribe_form_submit' + ) === -1 ) + ) { + return; + } + + let response; + try { + response = + typeof xhr.responseJSON !== 'undefined' + ? xhr.responseJSON + : JSON.parse( xhr.responseText ); + } catch ( e ) { + return; // Not JSON response. + } + + // Check for success (both forms use response.data). + const data = response.data || {}; + + // Contact form: data.error === false + // Subscribe form: !data.error + if ( data.error !== false && data.error ) { + return; // Form had errors. + } + + // Extract form type and node ID from AJAX data. + const params = new URLSearchParams( settings.data ); + const nodeId = params.get( 'node_id' ); + + if ( ! nodeId ) { + return; + } + + // Find the form element. + const $module = $( '.fl-node-' + nodeId ); + const $form = $module + .find( '.fl-contact-form, .fl-subscribe-form' ) + .first(); + + if ( ! $form.length ) { + return; + } + + // Determine form type from action. + const action = params.get( 'action' ); + let formType = 'unknown'; + if ( action === 'fl_builder_email' ) { + formType = 'contact'; + } else if ( action === 'fl_builder_subscribe_form_submit' ) { + formType = 'subscribe'; + } + + const formId = formType + '_' + nodeId; + const formInstanceId = formType + '_' + nodeId; + + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId, + formInstanceId, + } ); + } ); + + // Login Form - Note: Login forms redirect, so conversion tracked before redirect. + // No specific event handler needed as redirect happens immediately. +} diff --git a/assets/js/src/integration/bitform.js b/assets/js/src/integration/bitform.js new file mode 100644 index 000000000..f7a8417f1 --- /dev/null +++ b/assets/js/src/integration/bitform.js @@ -0,0 +1,50 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ + +/** + * Bit Form integration for Popup Maker. + * + * Tracks form submissions and triggers popup conversions. + */ +{ + const formProvider = 'bitform'; + const $ = window.jQuery; + + $( function () { + document + .querySelectorAll( '[id^="form-bitforms"]' ) + .forEach( function ( form ) { + form.addEventListener( + 'bf-form-submit-success', + function ( event ) { + if ( ! event.detail || ! event.detail.formId ) { + return; + } + + // Form identifier pattern: bitforms_{formId}_{postId}_{instanceCounter}. + // Where: + // - formId: Database form ID (e.g., "1") + // - postId: WordPress post/page ID where form is displayed (e.g., "995") + // - instanceCounter: 1-indexed instance if multiple forms on same page (e.g., "1") + // Extract formId and formInstanceId from full pattern. + // Example: bitforms_1_995_1 -> formId="1", formInstanceId="1" + const fullIdentifier = event.detail.formId.replace( + /^bitforms_/, + '' + ); + const parts = fullIdentifier.split( '_' ); + const formId = parts[ 0 ]; + const formInstanceId = parts[ 2 ] || null; + + // All the magic happens here. + window.PUM.integrations.formSubmission( $( form ), { + formProvider, + formId, + formInstanceId, + } ); + } + ); + } ); + } ); +} diff --git a/assets/js/src/integration/calderaforms.js b/assets/js/src/integration/calderaforms.js index 10f06d59e..7e58821da 100644 --- a/assets/js/src/integration/calderaforms.js +++ b/assets/js/src/integration/calderaforms.js @@ -10,8 +10,8 @@ /** * This function is run before every CF Ajax call to store the form being submitted. * - * @param event - * @param obj + * @param {Event} event + * @param {Object} obj */ const beforeAjax = ( event, obj ) => ( $form = obj.$form ); @@ -35,7 +35,10 @@ formId, formInstanceId, extras: { - state: window.cfstate.hasOwnProperty( formId ) + state: Object.prototype.hasOwnProperty.call( + window.cfstate, + formId + ) ? window.cfstate[ formId ] : null, }, diff --git a/assets/js/src/integration/elementor.js b/assets/js/src/integration/elementor.js new file mode 100644 index 000000000..f08e4e535 --- /dev/null +++ b/assets/js/src/integration/elementor.js @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ +{ + const formProvider = 'elementor'; + const $ = window.jQuery; + + // Elementor Forms success event. + $( document ).on( + 'submit_success', + '.elementor-form', + function ( event, response ) { + const $form = $( this )[ 0 ]; + + // Get element_id from the widget container. + // Elementor form widgets are inside a .elementor-element-{id} container. + const $widget = $( this ).closest( '[data-id]' ); + const elementId = $widget.length + ? $widget.attr( 'data-id' ) + : 'unknown'; + + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId: elementId, + } ); + } + ); +} diff --git a/assets/js/src/integration/fluentforms.js b/assets/js/src/integration/fluentforms.js index 5f6f6ff8e..260cf179e 100644 --- a/assets/js/src/integration/fluentforms.js +++ b/assets/js/src/integration/fluentforms.js @@ -7,15 +7,38 @@ $( document ).on( 'fluentform_submission_success', - function ( event, formDetails ) { - // Extract necessary form details from the event. - const formEl = formDetails.form; // The form element - const formConfig = formDetails.config; // The form configuration (contains formId, etc.) - const formId = formConfig.id; - const formInstanceId = formEl.data( 'form_instance' ); - console.log( formId, formDetails ); + function ( _event, formDetails ) { + // FluentForms fires this event twice per submission: + // 1. First with formDetails (complete data) + // 2. Second without formDetails (undefined) + // We only process the first event with actual data. + if ( + ! formDetails || + ! formDetails.config || + ! formDetails.config.id + ) { + return; + } + + const formId = formDetails.config.id; + + // FluentForms passes form as jQuery object in formDetails.form. + let formEl = formDetails.form; + + // Ensure it's a valid jQuery object with elements. + if ( ! formEl || ! formEl.length ) { + // Fallback: try to find form by ID attribute (FluentForms uses id="fluentform_X"). + formEl = $( '#fluentform_' + formId ); + } + + if ( ! formEl || ! formEl.length ) { + return; + } + + const formInstanceId = formEl.data( 'form_instance' ) || null; + // All the magic happens here. - window.PUM.integrations.formSubmission( $( formEl ), { + window.PUM.integrations.formSubmission( formEl, { formProvider, formId, formInstanceId, diff --git a/assets/js/src/integration/formidableforms.js b/assets/js/src/integration/formidableforms.js index 2e6bbf1a9..2bf421ce8 100644 --- a/assets/js/src/integration/formidableforms.js +++ b/assets/js/src/integration/formidableforms.js @@ -9,7 +9,7 @@ $( document ).on( 'frmFormComplete', function ( event, form, response ) { const $form = $( form ); const formId = $form.find( 'input[name="form_id"]' ).val(); - const $popup = PUM.getPopup( + const $popup = window.PUM.getPopup( $form.find( 'input[name="pum_form_popup_id"]' ).val() ); diff --git a/assets/js/src/integration/forminator.js b/assets/js/src/integration/forminator.js new file mode 100644 index 000000000..b2524a0c6 --- /dev/null +++ b/assets/js/src/integration/forminator.js @@ -0,0 +1,32 @@ +/******************************************************************************* + * Copyright (c) 2025, WP Popup Maker + ******************************************************************************/ +{ + const formProvider = 'forminator'; + const $ = window.jQuery; + + // Listen for Forminator's success event. + $( document ).on( + 'forminator:form:submit:success', + function ( event, formData ) { + // The form element that triggered the event. + const $form = $( event.target ); + + // Extract form ID from data-form-id attribute. + const formId = $form.attr( 'data-form-id' ); + + // Extract form instance ID from data-render-id attribute. + const formInstanceId = $form.attr( 'data-render-id' ); + + // All the magic happens here. + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId, + formInstanceId, + extras: { + formData, + }, + } ); + } + ); +} diff --git a/assets/js/src/integration/happyforms.js b/assets/js/src/integration/happyforms.js new file mode 100644 index 000000000..88596316e --- /dev/null +++ b/assets/js/src/integration/happyforms.js @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ +{ + const formProvider = 'happyforms'; + const $ = window.jQuery; + + $( document ).on( 'happyforms.submitted', function ( event, response ) { + // Only process successful submissions. + if ( ! response || ! response.success || ! response.data ) { + return; + } + + // Extract form element from event target. + const $form = $( event.target ); + + if ( ! $form.length ) { + return; + } + + // Extract form ID from hidden input. + const formId = $form.find( '[name="happyforms_form_id"]' ).val(); + + // Generate instance ID from form element index (for multiple instances of same form). + const $sameIdForms = $( 'form.happyforms-form' ).filter( function () { + return ( + $( this ).find( '[name="happyforms_form_id"]' ).val() === formId + ); + } ); + const formInstanceId = $sameIdForms.index( $form ) + 1; + + // All the magic happens here. + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId, + formInstanceId, + } ); + } ); +} diff --git a/assets/js/src/integration/htmlforms.js b/assets/js/src/integration/htmlforms.js new file mode 100644 index 000000000..912f14cdf --- /dev/null +++ b/assets/js/src/integration/htmlforms.js @@ -0,0 +1,45 @@ +/*************************************** + * HTML Forms Integration + * Copyright (c) 2024, Popup Maker + ***************************************/ + +{ + const formProvider = 'htmlforms'; + const $ = window.jQuery; + + /** + * Listen for HTML Forms success event + * + * Uses global html_forms.on('success') event that fires after successful submission + * Event fires with formElement as parameter + */ + $( () => { + if ( typeof window.html_forms !== 'undefined' ) { + window.html_forms.on( 'success', function ( formElement ) { + // Get form ID from data-id attribute. + const formId = + formElement.getAttribute( 'data-id' ) || + formElement.id?.replace( 'hf-form-', '' ) || + 'unknown'; + + // Generate instance ID from form element index (for multiple instances of same form). + const $sameIdForms = $( '.hf-form' ).filter( function () { + return ( + $( this ).attr( 'data-id' ) === formId || + $( this ).attr( 'id' )?.replace( 'hf-form-', '' ) === + formId + ); + } ); + + const formInstanceId = $sameIdForms.index( formElement ) + 1; + + // Trigger Popup Maker tracking. + window.PUM.integrations.formSubmission( $( formElement ), { + formProvider, + formId, + formInstanceId, + } ); + } ); + } + } ); +} diff --git a/assets/js/src/integration/index.js b/assets/js/src/integration/index.js index 1bea14d5b..dccd708d7 100644 --- a/assets/js/src/integration/index.js +++ b/assets/js/src/integration/index.js @@ -1,10 +1,18 @@ +import './beaverbuilder'; +import './bitform'; import './bricksbuilder'; import './calderaforms'; import './contactform7'; +import './elementor'; import './fluentforms'; import './formidableforms'; +import './forminator'; import './gravityforms'; +import './happyforms'; +import './htmlforms'; +import './kaliForms'; import './mc4wp'; +import './newsletter'; import './ninjaforms'; import './wpforms'; import './wsforms'; diff --git a/assets/js/src/integration/kaliForms.js b/assets/js/src/integration/kaliForms.js new file mode 100644 index 000000000..77b5d6aa7 --- /dev/null +++ b/assets/js/src/integration/kaliForms.js @@ -0,0 +1,122 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ +{ + const formProvider = 'kaliForms'; + const $ = window.jQuery; + + /** + * Kali Forms uses a custom submit event system with AJAX processing. + * Forms have ID format: kaliforms-form-{formId} + * Success is determined by response status in _processForm method. + */ + + /** + * Track form submission on successful processing. + * + * Kali Forms dispatches custom events during form processing. + * We listen to the document-level custom event after form processing completes. + */ + document.addEventListener( 'kaliformProcessCompleted', function ( event ) { + const detail = event.detail; + + if ( ! detail || ! detail.form ) { + return; + } + + const $form = $( detail.form ); + const formId = $form.data( 'form-id' ); + const formInstanceId = $form.attr( 'id' ); // e.g., "kaliforms-form-123" + + // All the magic happens here. + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId, + formInstanceId, + } ); + } ); + + /** + * Alternative approach: Listen for successful AJAX response. + * Kali Forms processes forms via AJAX action 'kaliforms_form_process'. + */ + $( document ).on( 'submit', 'form[data-form-id]', function ( event ) { + const $form = $( this ); + const formClass = $form.attr( 'class' ); + + // Check if this is a Kali Forms form. + if ( ! formClass || ! formClass.includes( 'kali-form' ) ) { + return; + } + + // Store reference for success handler. + const formId = $form.data( 'form-id' ); + const formInstanceId = $form.attr( 'id' ); + + // Listen for successful form processing via custom event. + const successHandler = function ( successEvent ) { + if ( + successEvent.detail && + successEvent.detail.formId === formId + ) { + window.PUM.integrations.formSubmission( $form, { + formProvider, + formId, + formInstanceId, + } ); + + // Remove the listener after firing once. + document.removeEventListener( + 'kaliFormSuccess', + successHandler + ); + } + }; + + document.addEventListener( 'kaliFormSuccess', successHandler ); + + // Cleanup listener after 30 seconds if not fired. + setTimeout( function () { + document.removeEventListener( 'kaliFormSuccess', successHandler ); + }, 30000 ); + } ); + + /** + * Add hidden popup ID field to Kali Forms inside popups. + * + * This ensures the form submission includes the popup ID for tracking. + */ + $( document ).on( 'pumAfterOpen', '.pum', function () { + const $popup = $( this ); + const popupId = window.PUM.getSetting( $popup, 'id' ); + + if ( ! popupId ) { + return; + } + + // Find all Kali Forms in this popup. + $popup.find( 'form[data-form-id]' ).each( function () { + const $form = $( this ); + + // Check if already has popup ID field. + if ( $form.find( 'input[name="pum_form_popup_id"]' ).length ) { + return; + } + + // Check if this is a Kali Forms form. + const formClass = $form.attr( 'class' ); + if ( ! formClass || ! formClass.includes( 'kali-form' ) ) { + return; + } + + // Add hidden field with popup ID. + $form.append( + $( '', { + type: 'hidden', + name: 'pum_form_popup_id', + value: popupId, + } ) + ); + } ); + } ); +} diff --git a/assets/js/src/integration/newsletter.js b/assets/js/src/integration/newsletter.js new file mode 100644 index 000000000..ba7279683 --- /dev/null +++ b/assets/js/src/integration/newsletter.js @@ -0,0 +1,195 @@ +/******************************************************************************* + * Copyright (c) 2024, WP Popup Maker + ******************************************************************************/ + +/** + * Newsletter Plugin Integration for Popup Maker + * + * Newsletter (thenewsletterplugin.com) uses fetch-based AJAX and replaces + * the form innerHTML with a success message. They don't fire any JavaScript + * events, so we use MutationObserver to detect when the form is replaced + * with the success/confirmation message. + * + * @param {jQuery} $ jQuery instance. + */ +( function ( $ ) { + const formProvider = 'newsletter'; + + /** + * Newsletter form selectors. + */ + const FORM_SELECTORS = + 'form.tnp-subscription, form.tnp-ajax, form[action*="newsletter"]'; + + /** + * Check if a form has been replaced with success content. + * Newsletter replaces form innerHTML on success - inputs disappear. + * Client-side validation prevents invalid submissions, so missing + * inputs means the form was successfully submitted. + * + * @param {HTMLFormElement} form The form element to check. + * @return {boolean} True if form inputs have been replaced. + */ + const isFormShowingSuccess = ( form ) => { + return ! form.querySelector( + 'input[type="text"], input[type="email"], input[type="submit"], button[type="submit"]' + ); + }; + + /** + * Check if form was completely removed from container. + * Used when Newsletter removes the form element entirely. + * + * @param {HTMLElement} element The container element to check. + * @return {boolean} True if no form exists in container. + */ + const formWasRemoved = ( element ) => { + return ! element.querySelector( 'form' ); + }; + + /** + * Handle successful form submission. + * + * @param {HTMLElement} container The form container element. + * @param {number|null} popupId The popup ID if inside a popup. + */ + const handleSuccess = ( container, popupId ) => { + // Prevent duplicate handling. + if ( container.dataset.pumNewsletterHandled ) { + return; + } + container.dataset.pumNewsletterHandled = 'true'; + + if ( ! window.PUM || ! window.PUM.integrations ) { + return; + } + + window.PUM.integrations.formSubmission( $( container ), { + formProvider, + formId: null, + formInstanceId: null, + extras: { + popupId, + }, + } ); + }; + + /** + * Set up MutationObserver for a Newsletter form. + * + * @param {HTMLFormElement} form The form element. + * @param {number|null} popupId The popup ID if inside a popup. + */ + const observeForm = ( form, popupId ) => { + const container = form.parentElement; + + if ( ! form || form.dataset.pumNewsletterObserved ) { + return; + } + + // Mark as observed to prevent duplicate observers. + form.dataset.pumNewsletterObserved = 'true'; + + const observer = new MutationObserver( () => { + // Newsletter replaces form innerHTML - check if form now shows success. + const formSuccess = isFormShowingSuccess( form ); + + // Also check if form was completely removed from container. + const formRemoved = + container && + ! container.contains( form ) && + formWasRemoved( container ); + + if ( formSuccess || formRemoved ) { + observer.disconnect(); + delete form.dataset.pumNewsletterObserved; + + // Small delay to ensure DOM is settled. + const target = formSuccess ? form : container; + setTimeout( () => handleSuccess( target, popupId ), 50 ); + } + } ); + + // Observe the form element itself for innerHTML changes. + observer.observe( form, { + childList: true, + subtree: true, + } ); + + // Also observe parent container in case form is completely replaced. + if ( container ) { + observer.observe( container, { + childList: true, + subtree: false, + } ); + } + + // Cleanup after 30 seconds if no submission. + setTimeout( () => { + observer.disconnect(); + delete form.dataset.pumNewsletterObserved; + }, 30000 ); + }; + + /** + * Initialize observers for all Newsletter forms. + */ + const initObservers = () => { + document.querySelectorAll( FORM_SELECTORS ).forEach( ( form ) => { + const $popup = $( form ).closest( '.pum' ); + const popupId = + $popup.length && window.PUM + ? window.PUM.getSetting( $popup, 'id' ) + : null; + + observeForm( form, popupId ); + } ); + }; + + /** + * Add hidden popup ID field to Newsletter forms inside popups. + * + * @param {jQuery} $popup The popup element. + * @param {number} popupId The popup ID. + */ + const injectPopupIdField = ( $popup, popupId ) => { + $popup.find( FORM_SELECTORS ).each( function () { + const $form = $( this ); + + if ( $form.find( 'input[name="pum_form_popup_id"]' ).length ) { + return; + } + + $form.append( + $( '', { + type: 'hidden', + name: 'pum_form_popup_id', + value: popupId, + } ) + ); + + // Set up observer for this form. + observeForm( this, popupId ); + } ); + }; + + // Initialize on DOM ready. + $( () => { + if ( ! window.PUM ) { + return; + } + + // Observe existing forms. + initObservers(); + + // When a popup opens, set up forms inside it. + $( document ).on( 'pumAfterOpen', '.pum', function () { + const $popup = $( this ); + const popupId = window.PUM.getSetting( $popup, 'id' ); + + if ( popupId ) { + injectPopupIdField( $popup, popupId ); + } + } ); + } ); +} )( window.jQuery ); diff --git a/assets/js/src/integration/ninjaforms.js b/assets/js/src/integration/ninjaforms.js index aca9a73a7..d6a1dcbe1 100644 --- a/assets/js/src/integration/ninjaforms.js +++ b/assets/js/src/integration/ninjaforms.js @@ -28,16 +28,17 @@ jqXHR, formIdentifier ) { - const $form = $( '#nf-form-' + formIdentifier + '-cont' ), - [ formId, formInstanceId = null ] = - formIdentifier.split( '_' ), - settings = {}; + const settings = {}; // Bail if submission failed. if ( response.errors && response.errors.length ) { return; } + const $form = $( '#nf-form-' + formIdentifier + '-cont' ); + const [ formId, formInstanceId = null ] = + formIdentifier.split( '_' ); + // All the magic happens here. window.PUM.integrations.formSubmission( $form, { formProvider, diff --git a/assets/js/src/site/plugins/pum-analytics.js b/assets/js/src/site/plugins/pum-analytics.js index 233de07dd..ce4e4057f 100644 --- a/assets/js/src/site/plugins/pum-analytics.js +++ b/assets/js/src/site/plugins/pum-analytics.js @@ -77,7 +77,8 @@ return; } catch ( error ) { - // Fall back to image beacon if sendBeacon fails + // Fall back to image beacon if sendBeacon fails. + // eslint-disable-next-line no-console console.warn( 'sendBeacon failed, falling back to image beacon:', error @@ -141,6 +142,13 @@ 10 ) || null, event: 'conversion', + eventData: { + type: 'form_submission', + formProvider: args.formProvider || null, + formId: args.formId || null, + formKey: args.formKey || null, + formInstanceId: args.formInstanceId || null, + }, }; // Shortcode popups use negative numbers, and single-popup (preview mode) shouldn't be tracked. diff --git a/assets/js/src/site/plugins/pum-url-tracking.js b/assets/js/src/site/plugins/pum-url-tracking.js index 6f2702e38..f840630cb 100644 --- a/assets/js/src/site/plugins/pum-url-tracking.js +++ b/assets/js/src/site/plugins/pum-url-tracking.js @@ -12,6 +12,7 @@ * * Handles: * - Appending pid (popup ID) to internal links within popups + * - Firing click conversion beacons for external/special links (mailto, tel, etc.) */ window.PUM_URLTracking = { /** @@ -44,6 +45,9 @@ /** * Process all links within a popup to add tracking parameters. * + * Internal links get ?pid= appended (tracked via server redirect). + * External/special links get click handlers for beacon tracking. + * * @param {jQuery} $popup The popup element. * @param {number} pid The popup ID. */ @@ -54,9 +58,8 @@ var $link = $( this ), href = $link.attr( 'href' ); - // Only process internal URLs. if ( self.isInternalUrl( href ) ) { - // Start with base URL parameters. + // Internal URLs: Append PID parameter (tracked via server redirect). var urlParams = { pid: pid }; // Allow extensions to add additional parameters. @@ -71,10 +74,110 @@ var newHref = self.appendParamsToUrl( href, urlParams ); $link.attr( 'href', newHref ); + } else if ( self.shouldTrackClick( href ) ) { + // External/special links: Attach click handler for beacon tracking. + self.attachClickTracking( $link, pid, href ); } } ); }, + /** + * Determine if a link should have click tracking attached. + * + * @param {string} url The URL to check. + * @return {boolean} True if click should be tracked. + */ + shouldTrackClick: function ( url ) { + // Skip empty URLs. + if ( ! url ) { + return false; + } + + // Skip links already tracked via CTA system. + if ( url.indexOf( 'cta=' ) !== -1 ) { + return false; + } + + return true; + }, + + /** + * Get the link type for analytics segmentation. + * + * @param {string} url The URL to categorize. + * @return {string} Link type: 'external', 'mailto', 'tel', or 'other'. + */ + getLinkType: function ( url ) { + if ( url.indexOf( 'mailto:' ) === 0 ) { + return 'mailto'; + } + if ( url.indexOf( 'tel:' ) === 0 ) { + return 'tel'; + } + if ( url.indexOf( 'javascript:' ) === 0 ) { + return 'javascript'; + } + if ( url === '#' || url.indexOf( '#' ) === 0 ) { + return 'anchor'; + } + if ( url.indexOf( 'http' ) === 0 || url.indexOf( '//' ) === 0 ) { + return 'external'; + } + return 'other'; + }, + + /** + * Attach click tracking to a link element. + * + * Fires a conversion beacon when the link is clicked. + * + * @param {jQuery} $link The link element. + * @param {number} pid The popup ID. + * @param {string} href The link URL. + */ + attachClickTracking: function ( $link, pid, href ) { + var self = this; + + // Prevent duplicate handlers. + if ( $link.data( 'pum-click-tracked' ) ) { + return; + } + $link.data( 'pum-click-tracked', true ); + + $link.on( 'click.pum_tracking', function () { + // Only track if analytics is available and enabled. + if ( + ! window.PUM_Analytics || + ! window.pum_vars || + ! window.pum_vars.analytics_enabled + ) { + return; + } + + var data = { + pid: pid, + event: 'conversion', + eventData: { + type: 'link_click', + url: href, + linkType: self.getLinkType( href ), + }, + }; + + // Allow extensions to modify click tracking data. + if ( window.PUM && window.PUM.hooks ) { + data = window.PUM.hooks.applyFilters( + 'popupMaker.popup.linkClickData', + data, + $link + ); + } + + // Fire beacon (sendBeacon queues even during navigation). + window.PUM_Analytics.beacon( data ); + } ); + }, + /** * Check if URL is internal to the current site. * @@ -86,6 +189,14 @@ return false; } + // Skip non-HTTP protocols (mailto:, tel:, javascript:, etc.). + if ( + /^[a-z][a-z0-9+.-]*:/i.test( url ) && + ! /^https?:/i.test( url ) + ) { + return false; + } + // Handle relative URLs. if ( url.indexOf( '/' ) === 0 && url.indexOf( '//' ) !== 0 ) { return true; diff --git a/bin/extract-changelog.js b/bin/extract-changelog.js new file mode 100644 index 000000000..070edc137 --- /dev/null +++ b/bin/extract-changelog.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +/** + * Extract changelog content for a specific version from CHANGELOG.md + * + * Usage: + * node bin/extract-changelog.js [version] + * node bin/extract-changelog.js 1.0.3 + * node bin/extract-changelog.js --latest # Extract latest released version + * node bin/extract-changelog.js --unreleased # Extract unreleased changes + */ + +const fs = require( 'fs' ); +const path = require( 'path' ); + +// Parse command line arguments +const args = process.argv.slice( 2 ); +const targetVersion = args[ 0 ]; + +if ( ! targetVersion ) { + console.error( + '❌ Usage: node bin/extract-changelog.js [version|--latest|--unreleased]' + ); + console.error( ' Examples:' ); + console.error( ' node bin/extract-changelog.js 1.0.3' ); + console.error( ' node bin/extract-changelog.js --latest' ); + console.error( ' node bin/extract-changelog.js --unreleased' ); + process.exit( 1 ); +} + +const changelogPath = path.join( process.cwd(), 'CHANGELOG.md' ); + +if ( ! fs.existsSync( changelogPath ) ) { + console.error( '❌ CHANGELOG.md not found in current directory' ); + process.exit( 1 ); +} + +const changelogContent = fs.readFileSync( changelogPath, 'utf8' ); + +/** + * Escape regex metacharacters in a string. + * + * @param {string} str - String to escape. + * @return {string} Escaped string safe for use in RegExp. + */ +function escapeRegex( str ) { + return str.replace( /[.+*?^$[\](){}|\\]/g, '\\$&' ); +} + +/** + * Extract the changelog section for a specific released version. + * + * @param {string} content - The full CHANGELOG.md text. + * @param {string} version - The version identifier to locate (e.g., "1.0.3"). + * @return {string|null} The trimmed changelog content for the given version, or `null` if the version is not present. + */ +function extractVersionContent( content, version ) { + // Escape version string to handle metacharacters like dots and plus signs + const escapedVersion = escapeRegex( version ); + + // Support multiple version formats: ## v1.0.3, ## 1.0.3, etc. + // Handles both LF and CRLF line endings + const versionPattern = new RegExp( + `^## (?:v)?${ escapedVersion }(?:\\s*-\\s*[0-9]{4}-[0-9]{2}-[0-9]{2})?\\s*\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |\\r?\\n?$)`, + 'm' + ); + + const match = content.match( versionPattern ); + return match ? match[ 1 ].trim() : null; +} + +/** + * Extract the content under the "Unreleased" section of a changelog. + * + * Captures the text after the "## Unreleased" heading up to the next "##" heading or end of file and trims surrounding whitespace. + * @param {string} content - Full changelog text. + * @return {string|null} The trimmed content of the "Unreleased" section, or `null` if the section is missing or empty. + */ +function extractUnreleasedContent( content ) { + // Handles both LF and CRLF line endings + const unreleasedPattern = + /^## Unreleased\s*([\s\S]*?)(?=\r?\n## |\r?\n?$)/m; + const match = content.match( unreleasedPattern ); + + if ( ! match || ! match[ 1 ].trim() ) { + return null; + } + + return match[ 1 ].trim(); +} + +/** + * Find the first released version entry after the "Unreleased" section and return its version and associated changelog text. + * + * @param {string} content - Complete CHANGELOG.md text. + * @return {{version: string, content: string}|null} An object with `version` (semantic version string) and `content` (trimmed section text) when a released version is found, `null` otherwise. + */ +function extractLatestVersion( content ) { + // Find the first version heading after Unreleased section + // Handles both LF and CRLF line endings + + // First, locate the Unreleased section + const unreleasedMatch = content.match( /^## Unreleased\s*\r?\n/m ); + + // Search for version after Unreleased, or from start if no Unreleased section exists + const searchStart = unreleasedMatch + ? unreleasedMatch.index + unreleasedMatch[ 0 ].length + : 0; + const searchContent = content.slice( searchStart ); + + // Find first semver version in the search content + const versionPattern = + /^## (?:v)?(\d+\.\d+\.\d+)(?:\s*-\s*[0-9]{4}-[0-9]{2}-[0-9]{2})?\s*\r?\n([\s\S]*?)(?=\r?\n## |\r?\n?$)/m; + const matches = searchContent.match( versionPattern ); + + if ( ! matches ) { + return null; + } + + return { + version: matches[ 1 ], + content: matches[ 2 ].trim(), + }; +} + +/** + * Prepare changelog content for use as a GitHub release body. + * + * Normalizes list formatting and trims surrounding whitespace. If `content` is falsy, + * returns a header that includes `version` and the message "No changelog content available." + * @param {string} content - Raw changelog content to format. + * @param {string} version - Version label used when no content is available. + * @return {string} Formatted changelog text suitable for a GitHub release body. + */ +function formatForGitHubRelease( content, version ) { + if ( ! content ) { + return `## ${ version }\n\nNo changelog content available.`; + } + + let formatted = content; + + // Ensure proper formatting for GitHub markdown + formatted = formatted + .replace( /^\s*[-*]\s+/gm, '- ' ) // Normalize bullet points + .trim(); + + return formatted; +} + +// Main execution +try { + let extractedContent = ''; + let versionNumber = ''; + + if ( targetVersion === '--unreleased' ) { + extractedContent = extractUnreleasedContent( changelogContent ); + versionNumber = 'Unreleased'; + + if ( ! extractedContent ) { + console.error( '❌ No unreleased changes found in CHANGELOG.md' ); + process.exit( 1 ); + } + } else if ( targetVersion === '--latest' ) { + const latest = extractLatestVersion( changelogContent ); + + if ( ! latest ) { + console.error( '❌ No released versions found in CHANGELOG.md' ); + process.exit( 1 ); + } + + extractedContent = latest.content; + versionNumber = latest.version; + } else { + // Extract specific version + extractedContent = extractVersionContent( + changelogContent, + targetVersion + ); + versionNumber = targetVersion; + + if ( ! extractedContent ) { + console.error( + `❌ Version ${ targetVersion } not found in CHANGELOG.md` + ); + process.exit( 1 ); + } + } + + // Format and output the content + const formattedContent = formatForGitHubRelease( + extractedContent, + versionNumber + ); + + // Output to stdout (can be captured by GitHub Actions) + console.log( formattedContent ); +} catch ( error ) { + console.error( '❌ Error processing changelog:', error.message ); + process.exit( 1 ); +} + +/* eslint-enable no-console */ diff --git a/classes/Admin/BlockEditor.php b/classes/Admin/BlockEditor.php index e71a892a0..a5deb630b 100644 --- a/classes/Admin/BlockEditor.php +++ b/classes/Admin/BlockEditor.php @@ -53,16 +53,29 @@ public static function register_editor_assets( $hook ) { } /** - * Register block assets. + * Register block editor JavaScript. + * + * Block styles are automatically enqueued by WordPress via block.json: + * - Frontend: On-demand when blocks are rendered + * - Editor: Always available for block inserter previews + * + * We only manually enqueue the block editor JavaScript in admin. + * + * @see https://make.wordpress.org/core/2025/03/24/new-filter-should_load_block_assets_on_demand-in-6-8/ * * @param string $hook Current page hook. */ public static function register_block_assets( $hook ) { + // Block editor JavaScript is admin-only. + // Block styles load automatically via block.json on both frontend and editor. + if ( ! is_admin() ) { + return; + } + + // Load block editor JavaScript when in popup editor. if ( self::load_block_library() ) { wp_enqueue_script( 'popup-maker-block-library' ); } - - wp_enqueue_style( 'popup-maker-block-library-style' ); } /** diff --git a/classes/Admin/Popups.php b/classes/Admin/Popups.php index 730d38704..4d79598a6 100644 --- a/classes/Admin/Popups.php +++ b/classes/Admin/Popups.php @@ -56,6 +56,11 @@ public static function init() { add_action( 'post_submitbox_misc_actions', [ __CLASS__, 'add_enabled_toggle_editor' ], 10, 1 ); + // Bulk actions for enabling/disabling popups. + add_filter( 'bulk_actions-edit-popup', [ __CLASS__, 'register_bulk_actions' ] ); + add_filter( 'handle_bulk_actions-edit-popup', [ __CLASS__, 'handle_bulk_actions' ], 10, 3 ); + add_action( 'admin_notices', [ __CLASS__, 'bulk_action_admin_notice' ] ); + add_filter( 'mce_buttons_2', [ __CLASS__, 'add_mce_buttons' ], 10, 1 ); add_filter( 'tiny_mce_before_init', [ __CLASS__, 'increase_available_font_sizes' ], 10, 1 ); } @@ -85,6 +90,146 @@ public static function add_enabled_toggle_editor( $post ) { is_valid() ) { + continue; + } + + // Only allow enabling/disabling published popups (matches UI toggle behavior). + if ( 'publish' !== get_post_status( $post_id ) ) { + ++$skipped; + continue; + } + + $popup->update_meta( 'enabled', $enabled ); + ++$count; + } + + $redirect_url = add_query_arg( + [ + 'pum_bulk_action' => $action, + 'pum_bulk_count' => $count, + 'pum_bulk_skipped' => $skipped, + ], + $redirect_url + ); + + return $redirect_url; + } + + /** + * Displays an admin notice after bulk enable/disable actions. + * + * @since 1.21.4 + */ + public static function bulk_action_admin_notice() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only, no action taken. + if ( empty( $_REQUEST['pum_bulk_action'] ) ) { + return; + } + + $screen = get_current_screen(); + + if ( ! $screen || 'edit-popup' !== $screen->id ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only, no action taken. + $action = sanitize_key( wp_unslash( $_REQUEST['pum_bulk_action'] ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only, no action taken. + $count = isset( $_REQUEST['pum_bulk_count'] ) ? intval( $_REQUEST['pum_bulk_count'] ) : 0; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only, no action taken. + $skipped = isset( $_REQUEST['pum_bulk_skipped'] ) ? intval( $_REQUEST['pum_bulk_skipped'] ) : 0; + + if ( 'pum_enable' === $action ) { + $message = sprintf( + /* translators: %d: Number of popups enabled. */ + _n( + '%d popup enabled.', + '%d popups enabled.', + $count, + 'popup-maker' + ), + $count + ); + } elseif ( 'pum_disable' === $action ) { + $message = sprintf( + /* translators: %d: Number of popups disabled. */ + _n( + '%d popup disabled.', + '%d popups disabled.', + $count, + 'popup-maker' + ), + $count + ); + } else { + return; + } + + // Add skipped notice if any popups were skipped due to not being published. + if ( $skipped > 0 ) { + $message .= ' ' . sprintf( + /* translators: %d: Number of popups skipped. */ + _n( + '%d popup skipped (not published).', + '%d popups skipped (not published).', + $skipped, + 'popup-maker' + ), + $skipped + ); + } + + printf( + '

%s

', + esc_html( $message ) + ); + } + /** * Adds the Popup ID right under the "Edit Popup" heading * @@ -1359,6 +1504,7 @@ public static function hide_columns( $hidden, $screen ) { */ public static function sortable_columns( $columns ) { $columns['popup_title'] = 'popup_title'; + $columns['enabled'] = 'enabled'; $columns['views'] = 'views'; $columns['conversions'] = 'conversions'; @@ -1388,6 +1534,16 @@ public static function sort_columns( $vars ) { ] ); break; + case 'enabled': + $vars = array_merge( + $vars, + [ + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_key' => 'enabled', + 'orderby' => 'meta_value_num', + ] + ); + break; case 'views': if ( ! pum_extension_enabled( 'popup-analytics' ) ) { $vars = array_merge( diff --git a/classes/Admin/Settings.php b/classes/Admin/Settings.php index 57363a9a5..ae7a1e5d8 100644 --- a/classes/Admin/Settings.php +++ b/classes/Admin/Settings.php @@ -250,6 +250,11 @@ public static function sanitize_settings( $settings = [] ) { $old = PUM_Utils_Options::get( $key ); $new = trim( $value ); + // If the value is starred (displayed for security), keep the existing unstarred value. + if ( strpos( $new, '*' ) !== false ) { + $new = $old; + } + if ( $old && $old !== $new ) { delete_option( str_replace( '_license_key', '_license_active', $key ) ); if ( ! empty( $field['options']['activation_callback'] ) ) { @@ -257,7 +262,7 @@ public static function sanitize_settings( $settings = [] ) { } } - $settings[ $key ] = is_string( $value ) ? trim( $value ) : $value; + $settings[ $key ] = $new; // Activate / deactivate license keys maybe? break; } @@ -525,7 +530,7 @@ public static function fields() { 'random' => __( 'Randomize Names', 'popup-maker' ), 'custom' => __( 'Custom Names', 'popup-maker' ), ], - 'std' => 'random', + 'std' => 'custom', 'dependencies' => [ 'bypass_adblockers' => true, ], @@ -945,12 +950,17 @@ public static function parse_values( $settings ) { // Handle other license keys using the legacy system $license = get_option( $field['options']['is_valid_license_option'] ); + $using_pro_license = ! empty( $field['options']['using_pro_license'] ); + $pro_license_tier = ! empty( $field['options']['pro_license_tier'] ) ? $field['options']['pro_license_tier'] : ''; + $settings[ $key ] = [ - 'key' => \PopupMaker\plugin( 'license' )->star_key( trim( $value ) ), - 'status' => PUM_Licensing::get_status( $license, ! empty( $value ) ), - 'messages' => PUM_Licensing::get_status_messages( $license, trim( $value ) ), - 'expires' => PUM_Licensing::get_license_expiration( $license ), - 'classes' => PUM_Licensing::get_status_classes( $license ), + 'key' => \PopupMaker\plugin( 'license' )->star_key( trim( $value ) ), + 'status' => PUM_Licensing::get_status( $license, ! empty( $value ) ), + 'messages' => PUM_Licensing::get_status_messages( $license, trim( $value ) ), + 'expires' => PUM_Licensing::get_license_expiration( $license ), + 'classes' => PUM_Licensing::get_status_classes( $license ), + 'using_pro_license' => $using_pro_license, + 'pro_license_tier' => $pro_license_tier, ]; break; } diff --git a/classes/Admin/Templates.php b/classes/Admin/Templates.php index 541b1d5f8..363d17311 100644 --- a/classes/Admin/Templates.php +++ b/classes/Admin/Templates.php @@ -198,11 +198,22 @@ public static function custom_fields() { <# var isActive = data.value.status === 'valid'; var shouldMask = isActive && data.value.key && data.value.key.length > 6; - var shouldDisable = isActive || data.value.auto_activated; + var usingProLicense = data.value.using_pro_license || false; + var proLicenseTier = data.value.pro_license_tier || ''; + var shouldDisable = isActive || data.value.auto_activated || usingProLicense; var displayValue = shouldMask ? data.value.key.substring(0,3) + '*'.repeat(data.value.key.length - 6) + data.value.key.slice(-3) : data.value.key; #> - <# if (data.value.auto_activated) { #> + <# if (usingProLicense) { #> + +

+ <# if (proLicenseTier === 'pro_plus') { #> + + <# } else { #> + + <# } #> +

+ <# } else if (data.value.auto_activated) { #>

@@ -218,7 +229,7 @@ public static function custom_fields() { <# } #> <# } #> - <# if (data.value.key !== '') { #> + <# if (data.value.key !== '' && !usingProLicense) { #> <# if (data.value.status === 'valid') { #> @@ -229,7 +240,7 @@ public static function custom_fields() { <# } #> <# } #> - <# if (data.value.messages && data.value.messages.length) { #> + <# if (data.value.messages && data.value.messages.length && !usingProLicense) { #>

<# for(var i=0; i < data.value.messages.length; i++) { #>

{{{data.value.messages[i]}}}

@@ -977,7 +988,7 @@ class="pum-doclink dashicons dashicons-editor-help" title="', content: content, - save_button: pum_admin_vars.I10n.add || '' + save_button: pum_admin_vars.I10n.add || '' })); #> diff --git a/classes/Analytics.php b/classes/Analytics.php index f89fd689c..29d500645 100644 --- a/classes/Analytics.php +++ b/classes/Analytics.php @@ -167,6 +167,30 @@ public static function endpoint_absint( $param ) { return is_numeric( $param ); } + /** + * Sanitize eventData parameter (matches Pro's approach). + * + * Decodes JSON string to array if needed. + * + * @param mixed $value EventData value (JSON string or array). + * + * @return array Decoded eventData as array. + */ + public static function sanitize_event_data( $value ) { + // If already an array, return as-is. + if ( is_array( $value ) ) { + return $value; + } + + // Decode JSON string to array (matches Pro's Request::parse_tracking_data). + if ( is_string( $value ) ) { + $decoded = json_decode( $value, true ); + return is_array( $decoded ) ? $decoded : []; + } + + return []; + } + /** * Registers the analytics endpoints */ @@ -181,18 +205,23 @@ public static function register_endpoints() { 'callback' => [ __CLASS__, 'analytics_endpoint' ], 'permission_callback' => '__return_true', 'args' => [ - 'event' => [ + 'event' => [ 'required' => true, 'description' => __( 'Event Type', 'popup-maker' ), 'type' => 'string', ], - 'pid' => [ + 'pid' => [ 'required' => true, 'description' => __( 'Popup ID', 'popup-maker' ), 'type' => 'integer', 'validation_callback' => [ __CLASS__, 'endpoint_absint' ], 'sanitize_callback' => 'absint', ], + 'eventData' => [ + 'required' => false, + 'description' => __( 'Event metadata (JSON or array)', 'popup-maker' ), + 'sanitize_callback' => [ __CLASS__, 'sanitize_event_data' ], + ], ], ] ) @@ -255,7 +284,7 @@ public static function get_analytics_route() { public static function customize_endpoint_value( $value = '' ) { $bypass_adblockers = pum_get_option( 'bypass_adblockers', false ); if ( true === $bypass_adblockers || 1 === intval( $bypass_adblockers ) ) { - switch ( pum_get_option( 'adblock_bypass_url_method', 'random' ) ) { + switch ( pum_get_option( 'adblock_bypass_url_method', 'custom' ) ) { case 'custom': $value = preg_replace( '/[^a-z0-9]+/', '-', pum_get_option( 'adblock_bypass_custom_filename', $value ) ); break; diff --git a/classes/AssetCache.php b/classes/AssetCache.php index 7a1fdb1ce..59f658bcd 100644 --- a/classes/AssetCache.php +++ b/classes/AssetCache.php @@ -140,6 +140,11 @@ public static function init() { add_action( 'wp_print_scripts', [ __CLASS__, 'localize_bundled_scripts' ], 10 ); + // Hook into WordPress script/style tag filters for ID obfuscation. + add_filter( 'script_loader_tag', [ __CLASS__, 'obfuscate_script_tag' ], 10, 3 ); + add_filter( 'style_loader_tag', [ __CLASS__, 'obfuscate_style_tag' ], 10, 4 ); + add_filter( 'wp_inline_script_attributes', [ __CLASS__, 'obfuscate_inline_script_id' ], 10, 2 ); + // Prevent reinitialization. self::$initialized = true; } @@ -253,7 +258,7 @@ public static function generate_cache_filename( $filename ) { $site_url = get_site_url(); - switch ( pum_get_option( 'adblock_bypass_url_method', 'random' ) ) { + switch ( pum_get_option( 'adblock_bypass_url_method', 'custom' ) ) { case 'random': $filename = md5( $site_url . $filename ); break; @@ -1186,4 +1191,174 @@ public static function get_bundled_style_dependencies() { return array_unique( $filtered_deps ); } + + /** + * Generate obfuscated handle name for script/style IDs. + * + * Uses same logic as cache filenames but incorporates handle for uniqueness. + * + * @since 1.21.0 + * + * @param string $handle The original script/style handle. + * @return string Obfuscated handle name. + */ + public static function generate_obfuscated_handle( $handle ) { + $site_url = get_site_url(); + $method = pum_get_option( 'adblock_bypass_url_method', 'custom' ); + + // Strip popup-maker-/pum- prefix for cleaner custom names. + $suffix = preg_replace( '/^(popup-maker-|pum-)/', '', $handle ); + + switch ( $method ) { + case 'random': + return md5( $site_url . $handle ); + + case 'custom': + default: + $prefix = pum_get_option( 'adblock_bypass_custom_filename', 'pm' ); + $prefix = ! empty( $prefix ) ? $prefix : 'pm'; + return sanitize_html_class( $prefix . '-' . $suffix ); + } + } + + /** + * Obfuscate script tag IDs for ad-blocker bypass. + * + * Uses script_loader_tag filter to catch ALL script output including + * inline scripts (-js-before, -js-after, -js-translations). + * + * @since 1.21.0 + * + * @param string $tag The complete