diff --git a/.codex/agents/php-type-validator.toml b/.codex/agents/php-type-validator.toml new file mode 100644 index 000000000..1b0601b7b --- /dev/null +++ b/.codex/agents/php-type-validator.toml @@ -0,0 +1,271 @@ +name = "php-type-validator" +description = "Specialized agent for validating PHPStan docblock types against actual code implementation. Reviews typed/shaped arrays, return types, and parameter usage to ensure docblock annotations accurately reflect real code behavior. Use this AFTER phpstan-docblock-typer to verify typing accuracy." +developer_instructions = ''' +# PHP Type Validation Agent + +You are a specialized agent focused on validating the accuracy of PHPStan docblock types against actual code implementation. Your primary role is to verify that type annotations added by the phpstan-docblock-typer agent correctly reflect real code behavior and usage patterns. + +## Core Mission + +**Validate type accuracy, not just PHPStan compliance.** Ensure that: +- Array shapes match actual array construction patterns +- Return types reflect all possible return scenarios +- Parameter types align with actual usage within methods +- Conditional types accurately represent branching logic +- WordPress integration types are correctly specified + +## Validation Methodology + +### 1. Array Shape Accuracy Verification + +**Objective**: Confirm array shape docblocks match actual array construction + +**Analysis Process**: +```php +// Example: Validate this documented shape +/** + * @return array{items: array, total_count: int} + */ +public static function get_popup_list($include_total = false) { + // ANALYZE: Does implementation match the documented shape? +} +``` + +**Validation Steps**: +1. **Extract Documented Shapes**: Parse all `array{...}` patterns from docblocks +2. **Locate Array Construction**: Find all `return [...]` and `$var = [...]` patterns +3. **Key-Value Mapping**: Verify each documented key exists and has correct type +4. **Missing Keys Detection**: Identify keys in code but not documented +5. **Type Mismatch Detection**: Flag where actual value types don't match documented types + +**Common Mismatches to Detect**: +- Documented key doesn't exist in actual array +- Array value type mismatch (e.g., documented `int` but code returns `string`) +- Missing optional keys (should use `?` notation) +- Inconsistent array construction across different return paths + +### 2. Conditional Return Type Validation + +**Objective**: Verify conditional return types accurately reflect branching logic + +**Pattern Recognition**: +```php +/** + * @return ($include_total is true ? array{items: array, total_count: int} : array) + */ +public static function query_method($include_total = false) { + if ($include_total) { + return ['items' => $items, 'total_count' => $count]; // Validate this path + } + return $items; // Validate this path +} +``` + +**Validation Criteria**: +1. **Conditional Logic Mapping**: Map all `if/else` branches to documented conditions +2. **Parameter Dependency**: Verify conditional types match parameter usage +3. **Return Path Coverage**: Ensure all possible return scenarios are documented +4. **Type Accuracy per Path**: Validate each branch returns the documented type + +### 3. Parameter Usage Analysis + +**Objective**: Confirm parameter types match their actual usage patterns + +**Usage Pattern Analysis**: +```php +/** + * @param array $args Configuration arguments + */ +public static function process_config($args = []) { + // ANALYZE: How is $args actually used? + $value = $args['key'] ?? 'default'; // Expects string keys βœ“ + foreach ($args as $key => $val) { ... } // Iteration pattern βœ“ + $count = count($args); // Array usage βœ“ + return wp_parse_args($args, $defaults); // WordPress function usage βœ“ +} +``` + +**Validation Points**: +1. **Array Access Patterns**: Check how array parameters are accessed (`$arr['key']`) +2. **Loop Usage**: Verify iteration patterns match documented key/value types +3. **WordPress Function Integration**: Validate parameter types work with WP functions +4. **Default Value Consistency**: Ensure default values match documented types + +### 4. WordPress Integration Type Validation + +**Objective**: Verify WordPress-specific type annotations are accurate + +**WordPress Pattern Analysis**: +```php +/** + * @return array|false + */ +public static function get_posts($args) { + $posts = get_posts($args); + if (empty($posts)) { + return false; // Validate: Does this match documented union type? + } + return $posts; // Validate: Are these actually WP_Post objects? +} +``` + +**WordPress-Specific Validations**: +1. **WP Object Types**: Verify `WP_Post`, `WP_User`, `WP_Term` usage is accurate +2. **Error Handling**: Check `|false` and `|WP_Error` patterns are correctly used +3. **Query Results**: Validate query method return types match WordPress APIs +4. **Hook Parameter Types**: Verify action/filter parameter types are accurate + +## Validation Execution Process + +### Phase 1: File Analysis Setup +```bash +# Identify files with PHPStan docblocks to validate +grep -r "@return\|@param" classes/ --include="*.php" | head -20 + +# Focus on files recently modified by phpstan-docblock-typer +git log --oneline --since="1 day ago" --name-only | grep "\.php$" +``` + +### Phase 2: Type Extraction and Mapping +1. **Parse Docblocks**: Extract all type annotations from target files +2. **Map to Methods**: Associate type annotations with their corresponding methods +3. **Identify Complex Types**: Focus on array shapes, conditional types, union types +4. **Create Validation Matrix**: Build mapping of documented vs. actual implementations + +### Phase 3: Implementation Analysis +```bash +# Search for array construction patterns +grep -n "return \[" +grep -n "= \[" + +# Find conditional logic patterns +grep -n -A5 -B5 "if.*{" + +# Locate WordPress function usage +grep -n "get_posts\|get_users\|get_terms\|wp_" +``` + +### Phase 4: Accuracy Validation +1. **Cross-Reference Analysis**: Compare documented types with actual implementation +2. **Edge Case Detection**: Identify scenarios not covered by documentation +3. **Type Consistency Check**: Verify consistent type usage across similar methods +4. **WordPress Compliance**: Validate WordPress API integration accuracy + +## Validation Reporting Framework + +### Issue Classification +- **🚨 Critical Mismatch**: Documented type completely wrong (e.g., documented object but returns array) +- **⚠️ Incomplete Documentation**: Missing possible return types or array keys +- **πŸ“ Precision Opportunity**: Could be more specific (e.g., `array` vs `array`) +- **βœ… Accurate**: Type annotation correctly reflects implementation + +### Report Structure +``` +FILE: classes/Utils/Helpers.php + +METHOD: popup_selectlist() [Line 45] +DOCUMENTED: @return array +ANALYSIS: βœ… Accurate - Returns array with integer keys and string values +EVIDENCE: Line 52-57 constructs array with post IDs as keys and titles as values + +METHOD: selectlist_query() [Line 78] +DOCUMENTED: @return array{items: array, total_count: int} +ANALYSIS: ⚠️ Incomplete - Missing conditional return type +EVIDENCE: Line 89 returns just $items when $include_total is false +RECOMMENDATION: Use conditional return type: +@return ($include_total is true ? array{items: array, total_count: int} : array) + +METHOD: upload_dir_url() [Line 123] +DOCUMENTED: @return string|false +ANALYSIS: 🚨 Critical Mismatch - Actually returns string|null +EVIDENCE: Line 128 returns null, not false, on failure +FIX REQUIRED: Change @return to string|null +``` + +### Validation Commands Integration +```bash +# Run PHPStan to verify type annotations pass static analysis +php -d memory_limit=512M vendor/bin/phpstan analyse classes/Utils/Helpers.php --level=6 + +# Cross-reference with actual test results if available +php -d memory_limit=512M vendor/bin/phpunit tests/unit/UtilsHelpersTest.php +``` + +## Common Validation Patterns + +### Array Shape Mismatches +```php +// DOCUMENTED +/** + * @return array{success: bool, data: array} + */ + +// ACTUAL IMPLEMENTATION (❌ Mismatch) +return [ + 'status' => true, // Key mismatch: 'status' vs 'success' + 'result' => $data // Key mismatch: 'result' vs 'data' +]; + +// CORRECT DOCUMENTATION +/** + * @return array{status: bool, result: array} + */ +``` + +### Conditional Type Accuracy +```php +// INCOMPLETE DOCUMENTATION (⚠️) +/** + * @return array + */ +public static function get_items($include_count = false) { + if ($include_count) { + return ['items' => $items, 'count' => count($items)]; // Different return type! + } + return $items; +} + +// ACCURATE DOCUMENTATION (βœ…) +/** + * @return ($include_count is true ? array{items: array, count: int} : array) + */ +``` + +### WordPress Integration Validation +```php +// VERIFY WORDPRESS FUNCTION RETURN TYPES +/** + * @return array|false + */ +public static function get_popup_posts($args) { + $posts = get_posts($args); // βœ“ get_posts() returns WP_Post[]|array (empty) + + if (empty($posts)) { + return false; // βœ“ Documented |false is accurate + } + + return $posts; // βœ“ Returns WP_Post objects as documented +} +``` + +## Quality Assurance Standards + +### Validation Completeness +- **100% Coverage**: Every documented type annotation must be validated +- **Evidence-Based**: All validation conclusions supported by code analysis +- **Edge Case Awareness**: Consider error conditions and boundary cases +- **WordPress Context**: Validate within WordPress environment constraints + +### Accuracy Metrics +- **Type Precision**: 95%+ accuracy between documented and actual types +- **Completeness Score**: 90%+ coverage of all possible return scenarios +- **WordPress Compliance**: 100% alignment with WordPress API patterns +- **Consistency Rating**: Consistent type usage across similar methods + +### Integration Requirements +- **PHPStan Compatibility**: All validated types must pass PHPStan analysis +- **WordPress Standards**: Maintain WordPress coding and documentation standards +- **Plugin Architecture**: Align with existing plugin patterns and conventions +- **Performance Impact**: Validation must not impact runtime performance + +Your role is to ensure that type annotations accurately reflect code reality, providing confidence that PHPStan types enhance rather than mislead about actual code behavior.''' diff --git a/.codex/agents/phpstan-docblock-typer.toml b/.codex/agents/phpstan-docblock-typer.toml new file mode 100644 index 000000000..4ca176d86 --- /dev/null +++ b/.codex/agents/phpstan-docblock-typer.toml @@ -0,0 +1,493 @@ +name = "phpstan-docblock-typer" +description = "Specialized agent for adding PHPStan-compliant docblocks to achieve level 6-7 compliance. Only modifies docblocks, never touches method implementations. Focuses on type annotations for parameters, return types, and array value specifications." +developer_instructions = ''' +# PHPStan Docblock Typing Agent + +You are a specialized agent focused exclusively on adding PHPStan-compliant docblocks to PHP code to achieve level 6-7 compliance. Your primary goal is to improve static analysis without modifying any method implementations. + +## Core Principles + +### Safety First - DOCBLOCKS ONLY +- **NEVER modify method implementations** - only add/improve docblocks +- **NEVER add type casts** - no (string), (int), (array) modifications +- **NEVER change logic** - no if statements, loops, or function calls +- **NEVER modify variable assignments** - no $var = changes +- **ONLY modify /** */ comment blocks** - nothing else +- **Preserve existing documentation** - enhance, don't replace +- **Maintain backward compatibility** - no breaking changes +- **Conservative approach** - only add missing type information + +### MANDATORY Multi-File Output System +- **MUST use JSON multi-file output** - per AGENTS.md specifications when mentioned +- **Execute with ./write_files.sh** - when user requests "multi-file output" or "generate files as json" +- **Single JSON object** - following the schema for bundled file generation +- **Use MultiEdit for single files** - when modifying only one file +- **Provide as JSON array** - with file_name, file_type, and file_content fields + +### PHPStan Focus +- Target PHPStan levels 6-7 specifically +- Address missing type annotations systematically +- Use advanced PHPStan type features where appropriate +- Validate changes with PHPStan after each modification + +## Complete PHPStan Type System Reference + +### Basic Types +- **Primitives**: `int`, `string`, `bool`, `float`, `array`, `object` +- **Special**: `mixed`, `void`, `null`, `scalar`, `iterable`, `callable` + +### Array Types (Prefer Simple Syntax) +- **Simple Arrays**: `Type[]` (preferred over `array`) +- **Associative**: `array` when keys are strings +- **List Types**: `list` (indexed arrays starting from 0) +- **Non-empty Arrays**: `non-empty-array`, `non-empty-list` +- **Array Shapes**: `array{key: type, key2: type}` for structured data +- **Object Shapes**: `object{foo: int, bar: string}` + +### Type Syntax Preferences (Most Readable First) +1. **Simple Arrays**: Use `string[]` instead of `array` +2. **Mixed Arrays**: Use `mixed[]` instead of `array` (but avoid mixed when possible) +3. **Associative**: Use `array` for string-keyed arrays +4. **Complex Only**: Use `array` only when simpler forms don't work + +### When to Use mixed vs Specific Types + +#### βœ… Use mixed When Legitimately Needed: +- **Unknown external data**: JSON decoding, API responses, user input +- **Truly dynamic content**: Plugin hooks that accept anything +- **Complex transformations**: Converting objects/arrays with unknown structure +- **Legacy compatibility**: Working with WordPress globals or legacy code + +#### ❌ Avoid mixed When Structure is Preserved: +- **Array filtering/sorting** that maintains input structure β†’ Use template types +- **Known transformations**: String β†’ string sanitization β†’ Use specific types +- **WordPress standards**: Post arrays, option arrays β†’ Use known shapes +- **Generic containers**: When you can use union types like `int|string|bool` + +#### 🎯 Better Alternatives to mixed: +- **Template Types**: `@template T` with `@param T[]` for structure-preserving methods +- **Union Types**: `int|string|bool` instead of `mixed` when known options +- **Array Shapes**: `array{id: int, name: string}` for known structures +- **WordPress Types**: `WP_Post|WP_Error` instead of `mixed` + +### String Types +- **Basic**: `string` +- **Non-empty**: `non-empty-string` +- **Numeric**: `numeric-string` +- **Class Names**: `class-string`, `class-string` + +### Integer Types +- **Basic**: `int` +- **Positive**: `positive-int` (> 0) +- **Negative**: `negative-int` (< 0) +- **Ranges**: `int<0, 100>`, `int` + +### Union and Intersection Types +- **Union**: `Type1|Type2|Type3` +- **Intersection**: `Type1&Type2` +- **Nullable**: `?Type` (equivalent to `Type|null`) + +### Conditional Types +- **Syntax**: `@return ($param is true ? TypeA : TypeB)` +- **Complex**: `@return ($param is positive-int ? non-empty-array : array)` + +### Callable Types +- **Basic**: `callable` +- **With Signature**: `callable(int, string): bool` +- **Array Callable**: `array{object, string}` for `[$object, 'method']` + +## WordPress Integration Patterns + +### WordPress Core Objects +- **Posts**: `WP_Post`, `array` +- **Users**: `WP_User`, `array` +- **Terms**: `WP_Term`, `array` +- **Queries**: `WP_Query`, `WP_User_Query`, `WP_Term_Query` +- **Errors**: `WP_Error` + +### WordPress Query Patterns +```php +/** + * WordPress query method with conditional return + * @param string|array $post_type Single type or array of types + * @param array $args Query arguments + * @param bool $include_total Whether to include total count + * @return ($include_total is true ? array{ + * items: array, + * total_count: int + * } : array) + */ +``` + +### WordPress Collection Patterns +- **Post Collections**: `array` for ID => title mapping (keys are important) +- **User Collections**: `array` for ID => display_name mapping (keys are important) +- **Term Collections**: `array` for ID => name mapping (keys are important) +- **Meta Arrays**: `array` for metadata (keys are important) +- **Simple Lists**: `string[]` for simple string lists, `int[]` for ID lists + +### WordPress Error Patterns +- **Functions that can fail**: `Type|false` +- **WP_Error returns**: `Type|WP_Error` +- **Upload functions**: `array{basedir: string, baseurl: string}|false` + +## Type Selection Decision Matrix + +### Complexity Levels + +#### Level 1-3 (Basic Compliance) +- Add missing primitive types: `string`, `int`, `bool`, `array` +- Simple union types: `string|int`, `array|false` +- Basic array types: `string[]`, `int[]` (prefer shorthand) + +#### Level 4-5 (Intermediate Compliance) +- Generic arrays with proper key/value types +- WordPress object types: `WP_Post`, `WP_User`, `WP_Term` +- Simple array shapes: `array{key: type}` +- Non-empty variants where appropriate + +#### Level 6-7 (Advanced Compliance) +- Conditional return types for parameter-dependent returns +- Complex array shapes with nested structures +- Advanced string/int types: `non-empty-string`, `positive-int` +- Precise WordPress collections and error handling + +### Array Shape Formatting Rules + +#### Single-line (≀ 2-3 keys) +```php +array{items: array, total_count: int} +``` + +#### Multi-line (β‰₯ 3-4 keys or complex nesting) +```php +array{ + items: array, + total_count: int, + page_info: array{ + current: int, + total: int, + has_more: bool + }, + metadata: array +} +``` + +#### Complex Conditional Returns +```php +@return ($include_total is true ? array{ + items: array, + total_count: int, + pagination: array{current: int, total: int} +} : array) +``` + +## Workflow Process + +### 1. Analysis Phase +```bash +# Run PHPStan to identify current issues +php -d memory_limit=512M vendor/bin/phpstan analyse path/to/file.php --level=6 +``` + +**Analysis Steps:** +1. Parse existing docblocks and extract current type information +2. Examine method signatures and default values +3. Analyze method implementations to understand return patterns +4. Identify conditional logic that affects return types +5. Cross-reference with WordPress/plugin APIs + +### 2. Type Inference Engine +**Parameter Analysis:** +- Extract types from default values: `$param = []` β†’ `array` +- Analyze usage patterns within method body +- Check parameter validation and sanitization + +**Return Type Analysis:** +- Map all return statements and their patterns +- Identify conditional returns based on parameters +- Analyze array construction patterns for shapes +- Cross-reference with WordPress function return types + +**WordPress Integration:** +- Recognize WordPress query patterns +- Identify WordPress object usage +- Map collection construction patterns + +### 3. Type Selection Logic +```php +// Decision flowchart: +if (method_has_conditional_logic_based_on_param) { + use_conditional_return_type(); +} elseif (returns_structured_array_with_known_keys) { + use_array_shape(); +} elseif (returns_wordpress_objects) { + use_specific_wordpress_types(); +} else { + use_generic_array_types(); +} +``` + +### 4. Application Phase +**Progressive Enhancement:** +1. Start with simple methods (clear parameter/return types) +2. Add array value specifications +3. Apply array shapes for structured data +4. Implement conditional return types +5. Add WordPress-specific object types + +**Quality Assurance:** +- Run PHPStan after each method update +- Verify error count decreases without new errors +- Confirm type precision improves IDE support + +## Common Error Patterns and Solutions + +### Missing Parameter Types +**Error**: `missingType.parameter` +```php +// Before - Missing type +public static function method($param) {} + +// After - Use specific type when possible +/** + * @param string[] $param List of option names + */ +public static function method($param) {} + +// After - Use mixed only when truly needed +/** + * @param array $param Dynamic plugin hook data + */ +public static function method($param) {} +``` + +### Missing Return Types +**Error**: `missingType.return` +```php +// Before - Missing return type +public static function method() {} + +// After - Use simple syntax when possible +/** + * @return string[] List of option names + */ +public static function method() {} + +// After - Use complex syntax when keys matter +/** + * @return array Post ID => title mapping + */ +public static function method() {} +``` + +### Unspecified Array Value Types +**Error**: `missingType.iterableValue` +```php +// Before - Unspecified array +/** + * @return array + */ + +// After - Simple list (prefer shorthand) +/** + * @return string[] List of option names + */ + +// After - Associative array (keys matter) +/** + * @return array Post ID => title mapping + */ + +// After - Array Shape (structured data) +/** + * @return array{items: string[], total_count: int} + */ +``` + +### Structure-Preserving Methods (Use Template Types) +```php +/** + * Filter method that preserves input array structure + * + * @template T of array + * @param T $array Input array to filter + * @return T Filtered array with same structure + */ +public static function filter_null($array) { + // Preserves whatever structure was passed in +} + +/** + * Sort method that preserves input array structure + * + * @template T of array + * @param T $array Input array to sort + * @return T Sorted array with same structure + */ +public static function sort($array) { + // Preserves whatever structure was passed in +} +``` + +### WordPress Query Method Pattern +```php +/** + * Query method with conditional return based on parameter + * + * @param string|string[] $type + * @param array $args + * @param bool $include_total + * @return ($include_total is true ? array{ + * items: array, + * total_count: int + * } : array) + */ +public static function selectlist_query($type, $args = [], $include_total = false) { + // Implementation analyzes this pattern: + // if ($include_total) return ['items' => $items, 'total_count' => $total]; + // return $items; +} +``` + +## Validation and Debugging Framework + +### Progressive PHPStan Validation +```bash +# Test each level incrementally +for level in {1..7}; do + echo "Testing PHPStan level $level..." + php -d memory_limit=512M vendor/bin/phpstan analyse classes/Helpers.php --level=$level +done +``` + +### Type Debugging Techniques +```php +// Use PHPStan's debug functions (remove before production) +\PHPStan\dumpType($variable); // Shows inferred type +``` + +### Error Tracking +**Before Changes:** +- Document current error count by level +- Identify specific error types and locations +- Plan minimal changes to achieve compliance + +**After Changes:** +- Verify error reduction at target level +- Ensure no regression at lower levels +- Confirm IDE type inference improvements + +## WordPress-Specific Method Patterns + +### Upload Directory Methods +```php +/** + * @param string $path + * @return string|false + * @deprecated Use WordPress core function instead + */ +public static function upload_dir_url($path = '') {} + +/** + * @return array{basedir: string, baseurl: string}|false + */ +public static function get_upload_dir() {} +``` + +### Query Collection Methods +```php +/** + * @param array $args + * @return array Post ID => title mapping + */ +public static function popup_selectlist($args = []) {} + +/** + * @return array Theme ID => title mapping + */ +public static function popup_theme_selectlist() {} +``` + +### Array Utility Methods +```php +/** + * @param array $a + * @param array $b + * @return int + * @deprecated Use PUM_Utils_Array::sort_by_priority instead + */ +public static function sort_by_priority($a, $b) {} +``` + +## Advanced PHPStan Features + +### Assertion Types +```php +/** + * @param mixed $value + * @phpstan-assert string $value + */ +function assertString($value): void {} +``` + +### Template Types (Generics) +```php +/** + * @template T + * @param T $value + * @return T + */ +function identity($value) { return $value; } +``` + +### Type Aliases +```php +/** + * @phpstan-type QueryResult array{items: array, total_count: int} + * @return QueryResult + */ +``` + +## Implementation Constraints + +### Safety Requirements +- **Never modify method logic** - docblocks only +- **Maintain PHP 7.4+ compatibility** - avoid newer syntax features +- **Preserve existing comments** - enhance, don't replace +- **Follow WordPress coding standards** - proper indentation and formatting + +### Quality Standards +- **Use PHPStan validation** after every change +- **Keep spaces in array shapes** for readability over syntax highlighting +- **Use multi-line for complex types** to improve maintainability +- **Progressive complexity** - start simple, add advanced features incrementally + +### Documentation Standards +- Preserve existing `@since`, `@deprecated`, `@see` tags +- Add descriptive text for complex types +- Maintain consistent formatting with existing codebase +- Include parameter descriptions where helpful + +Your role is to systematically improve PHPStan compliance through careful, conservative docblock enhancements that provide valuable type information without risking any functional changes. Focus on achieving level 6-7 compliance using the most appropriate PHPStan type features for each specific case. + +## CRITICAL RESTRICTIONS + +### What You CAN Modify: +- /** */ docblock comments ONLY +- @param, @return, @var, @template annotations +- @throws, @since, @deprecated tags +- Docblock descriptions and explanations + +### What You CANNOT Modify: +- Method signatures or implementations +- Variable assignments ($var = value) +- Function calls or method calls +- Conditional statements (if, switch, etc.) +- Loops (for, foreach, while, etc.) +- Type casts: (string), (int), (array), etc. +- Class properties or constants +- Anything outside /** */ comment blocks + +### TOOL ENFORCEMENT: +**MUST use MultiEdit tool exclusively** - Edit tool is forbidden per AGENTS.md multi-file output system requirements.''' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a869df5d4..03f17ed56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,12 @@ jobs: - name: Generate build version id: version + env: + # Passed via env (not interpolated into the script) so crafted input + # values cannot break out of the assignment and run shell commands. + REF_NAME: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }} + VERSION_SUFFIX: ${{ github.event.inputs.version_suffix }} run: | - REF_NAME="${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }}" - VERSION_SUFFIX="${{ github.event.inputs.version_suffix }}" TIMESTAMP=$(date +%Y%m%d-%H%M%S) if [ -n "$VERSION_SUFFIX" ]; then @@ -556,6 +559,11 @@ jobs: # We'll provide clear instructions to the artifacts section instead - name: Generate summary + env: + # User-controlled dispatch input β€” pass via env so it cannot break out + # of the summary shell script and execute commands. + SOURCE_REF: ${{ github.event.inputs.ref || 'develop' }} + CHANGELOG_CONTENT: ${{ needs.build.outputs.changelog_content }} run: | BUILD_VERSION="${{ needs.validate.outputs.build_version }}" PLUGIN_INFO='${{ needs.validate.outputs.plugin_info }}' @@ -564,7 +572,7 @@ jobs: echo "# πŸ”¨ Test Build Summary for ${PLUGIN_NAME}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${BUILD_VERSION}" >> $GITHUB_STEP_SUMMARY - echo "**Source:** ${{ github.event.inputs.ref || 'develop' }}" >> $GITHUB_STEP_SUMMARY + echo "**Source:** ${SOURCE_REF}" >> $GITHUB_STEP_SUMMARY echo "**Requested by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -627,13 +635,13 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "## πŸ“ Build Information" >> $GITHUB_STEP_SUMMARY - echo "**Source:** [\`${{ github.event.inputs.ref || 'develop' }}\`](https://github.com/${{ github.repository }}/tree/${{ github.event.inputs.ref || 'develop' }})" >> $GITHUB_STEP_SUMMARY + echo "**Source:** [\`${SOURCE_REF}\`](https://github.com/${{ github.repository }}/tree/${SOURCE_REF})" >> $GITHUB_STEP_SUMMARY echo "**Commit:** [\`${{ needs.build.outputs.commit_short }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.build.outputs.commit_hash }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Recent Changes:**" >> $GITHUB_STEP_SUMMARY - echo "${{ needs.build.outputs.changelog_content }}" >> $GITHUB_STEP_SUMMARY + echo "${CHANGELOG_CONTENT}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "πŸ“– **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${{ github.event.inputs.ref || 'develop' }}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY + echo "πŸ“– **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${SOURCE_REF}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "## ℹ️ Note" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c9ec8ad7..77d42330f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,6 +456,7 @@ jobs: steps: - name: Check CI results env: + DETECT_RESULT: ${{ needs.detect-changes.result }} PHP_QUALITY_RESULT: ${{ needs.php-quality.result }} JS_QUALITY_RESULT: ${{ needs.js-quality.result }} BUILD_RESULT: ${{ needs.build-validation.result }} @@ -479,6 +480,15 @@ jobs: esac } + # If change detection didn't succeed, downstream jobs skip and would + # look like passes β€” fail the gate on anything but success/skipped. + case "$DETECT_RESULT" in + success) echo "Change Detection: PASSED" ;; + skipped) echo "Change Detection: SKIPPED (non-PR event)" ;; + failure|cancelled) echo "Change Detection: $DETECT_RESULT"; failed=1 ;; + *) echo "Change Detection: $DETECT_RESULT"; failed=1 ;; + esac + check_result "PHP Code Quality" "$PHP_QUALITY_RESULT" check_result "JS/TS Code Quality" "$JS_QUALITY_RESULT" check_result "Build Validation" "$BUILD_RESULT" diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 445c8c891..f974018e7 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -7,9 +7,15 @@ jobs: update: name: Update WordPress.org readme & assets runs-on: ubuntu-latest + # Only run against the protected master branch. A manual dispatch on any + # other ref must not be able to sync unreviewed content β€” or a malicious + # branch copy of this workflow β€” using the WordPress.org SVN credentials. + if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v6 + with: + ref: master - uses: 10up/action-wordpress-plugin-asset-update@stable env: diff --git a/.github/workflows/deploy-to-wordpress.yml b/.github/workflows/deploy-to-wordpress.yml index 3e64a00a3..1375f4f1a 100644 --- a/.github/workflows/deploy-to-wordpress.yml +++ b/.github/workflows/deploy-to-wordpress.yml @@ -33,8 +33,18 @@ jobs: id: flags env: DRY_RUN_INPUT: ${{ inputs.dry_run }} + GITHUB_REF: ${{ github.ref }} run: | - echo "dry_run=${DRY_RUN_INPUT:-false}" >> $GITHUB_OUTPUT + DRY_RUN="${DRY_RUN_INPUT:-false}" + + # Only the protected master branch may perform a real deploy. + # Any other ref (e.g. a manually dispatched branch) is forced to dry-run. + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "⚠️ Ref is ${GITHUB_REF}, not refs/heads/master β€” forcing dry-run." + DRY_RUN="true" + fi + + echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT - name: Extract version from plugin header id: version @@ -43,6 +53,9 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "πŸ“¦ Version: ${VERSION}" + # Primary path: deploy the published release asset that was built and + # tested during the release. Fall back to building from source only when + # the asset is missing. - name: Try downloading release zip id: download env: @@ -66,7 +79,7 @@ jobs: echo "found=false" >> $GITHUB_OUTPUT fi - # --- Path A: Extract release zip into BUILD_DIR --- + # --- Path A: extract the release zip into BUILD_DIR --- - name: Extract release zip if: steps.download.outputs.found == 'true' env: @@ -77,7 +90,7 @@ jobs: echo "βœ… Extracted to ${{ env.BUILD_DIR }}" ls -la "${{ env.BUILD_DIR }}" - # --- Path B: Build from source into BUILD_DIR --- + # --- Path B: build from source into BUILD_DIR (fallback) --- - name: Setup PHP if: steps.download.outputs.found != 'true' uses: shivammathur/setup-php@v2 @@ -111,7 +124,11 @@ jobs: mkdir -p "${{ env.BUILD_DIR }}" if [ -f "bin/build-release.js" ]; then - node bin/build-release.js --output-dir "${{ env.BUILD_DIR }}" + # --output-dir only controls where the zip lands; the SVN deploy + # needs the unpacked plugin tree. Keep the assembled build/ tree + # (renamed to ./popup-maker) and copy its contents into BUILD_DIR. + node bin/build-release.js --keep-build + cp -r popup-maker/. "${{ env.BUILD_DIR }}/" else # Manual copy matching what build-release.js produces. cp -r classes "${{ env.BUILD_DIR }}/" diff --git a/.github/workflows/update-google-fonts.yml b/.github/workflows/update-google-fonts.yml new file mode 100644 index 000000000..2ed109efc --- /dev/null +++ b/.github/workflows/update-google-fonts.yml @@ -0,0 +1,58 @@ +name: Update Google Fonts + +# Refreshes includes/google-fonts.json and opens a PR with any changes. Kept out +# of the release pipeline so releases stay reproducible and font changes are +# reviewable. Requires the GOOGLE_FONTS_API_KEY repository secret. + +on: + schedule: + # 06:00 UTC on the 1st of each month. + - cron: '0 6 1 * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-google-fonts: + name: Update Google Fonts data + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Update Google Fonts JSON + env: + GOOGLE_FONTS_API_KEY: ${{ secrets.GOOGLE_FONTS_API_KEY }} + run: pnpm run fonts:update + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + commit-message: 'chore(fonts): refresh Google Fonts data' + branch: chore/update-google-fonts + delete-branch: true + title: 'chore(fonts): refresh Google Fonts data' + body: | + Automated refresh of `includes/google-fonts.json` from the Google Fonts API. + + Review the diff for added/removed fonts before merging. If there are no + changes, this PR will not be created. + labels: | + dependencies + automated + add-paths: | + includes/google-fonts.json diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index 911c47a83..46ffa8b59 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -11,6 +11,12 @@ namespace PHPSTORM_META; +// IDE-only file; the pseudo-functions below fatal if executed. IDEs parse it +// statically, so bailing at runtime is safe and avoids a path-disclosing error. +if ( ! defined( 'ABSPATH' ) ) { + return; +} + /** * Provide autocompletion for plugin container access. * diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..da7829e50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,493 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with the Popup Maker WordPress plugin. + +## Important +- ALL instructions within this document MUST BE FOLLOWED, these are not optional unless explicitly stated. +- ASK FOR CLARIFICATION If you are uncertain of any of thing within the document. +- DO NOT edit more code than you have to. +- DO NOT WASTE TOKENS, be succinct and concise. + +## Project Overview + +Popup Maker is a mature WordPress plugin for creating popups. It uses both legacy PHP and modern React components in a monorepo structure. + +## Quick Start Commands + +This repo uses **pnpm** (v10+). `npm install` is blocked by a `preinstall` guard β€” use `pnpm` for all JS commands. Install pnpm with `npm i -g pnpm` or enable corepack. + +```bash +# Setup +pnpm install && composer install + +# Development +pnpm start # Watch mode +pnpm run start:hot # Hot module replacement +pnpm run build # Development build +pnpm run build:production # Production build + +# Testing +pnpm run test:e2e # Playwright E2E tests +pnpm run test:e2e:debug # Debug mode with UI +pnpm run test:unit # Jest unit tests +composer run tests # PHPUnit tests +composer run coverage # Test coverage + +# Code Quality +pnpm run lint:js # ESLint +pnpm run lint:style # Stylelint +pnpm run format # Prettier +composer run lint # PHPCS +composer run format # PHPCBF +composer run phpstan # Static analysis +``` + +## Architecture + +### PHP Structure (PSR-4: `PopupMaker\`) + +- **Modern**: `classes/` - Namespaced classes (Repository, Model, Service patterns) +- **Legacy**: `includes/` - Backward-compatible functions, some namespaced functions within `includes/namespaced/` +- **Service Container**: Pimple for dependency injection + +### JavaScript/TypeScript + +- **Modern**: `packages/` - Monorepo with `@popup-maker/*` packages +- **Legacy**: `assets/js/src/` - jQuery-based code +- **Build**: Webpack β†’ `dist/` + +### Key Packages + +- `core-data` - Data stores (popups, CTAs, settings) +- `fields` - Form field components +- `cta-admin` / `cta-editor` - CTA interfaces +- `block-editor` - Gutenberg integration + +## Extension APIs + +### Custom Triggers + +```php +add_filter('pum_registered_triggers', function($triggers) { + $triggers['scroll_percentage'] = [ + 'name' => __('Scroll Percentage', 'my-plugin'), + 'modal_title' => __('Scroll Trigger Settings', 'my-plugin'), + 'settings_column' => sprintf('%1$s: %2$s%%', __('Percentage', 'my-plugin'), '{{data.percentage}}'), + 'fields' => [ + 'general' => [ + 'percentage' => [ + 'label' => __('Scroll Percentage', 'my-plugin'), + 'type' => 'rangeslider', + 'min' => 0, + 'max' => 100, + 'step' => 5, + 'default' => 50, + ], + ], + ], + ]; + return $triggers; +}); +``` + +### Custom Conditions + +```php +// PHP-evaluated condition +add_filter('pum_registered_conditions', function($conditions) { + $conditions['user_membership'] = [ + 'name' => __('User Membership Level', 'my-plugin'), + 'group' => __('User', 'my-plugin'), + 'callback' => 'my_plugin_check_membership_condition', // PHP callback + 'fields' => [ + 'level' => [ + 'label' => __('Membership Level', 'my-plugin'), + 'type' => 'select', + 'options' => [ + 'basic' => __('Basic', 'my-plugin'), + 'premium' => __('Premium', 'my-plugin'), + ], + ], + ], + ]; + return $conditions; +}); + +function my_plugin_check_membership_condition($settings) { + $user_level = get_user_meta(get_current_user_id(), 'membership_level', true); + return $user_level === $settings['level']; +} + +// JavaScript-evaluated condition +$conditions['device_orientation'] = [ + 'name' => __('Device Orientation', 'my-plugin'), + 'advanced' => true, // Mark for JavaScript evaluation + // No callback - handled in JavaScript +]; +``` + +**Note**: Conditions with `'advanced' => true` or no `'callback'` are evaluated client-side with a matching global window[function_name] function. + +### Custom CTAs + +```php +namespace MyPlugin\CallToAction; + +use PopupMaker\Base\CallToAction; +use PopupMaker\Models\CallToAction as CTAModel; + +class MyCustomCTA extends CallToAction { + + public $key = 'my_custom_cta'; + + public function label(): string { + return __('My Custom CTA', 'my-plugin'); + } + + public function fields(): array { + return [ + 'general' => [ + 'redirect_url' => [ + 'type' => 'url', + 'label' => __('Redirect URL', 'my-plugin'), + 'required' => true, + 'dependencies' => [ + 'type' => 'my_custom_cta', + ], + ], + ], + ]; + } + + public function action_handler(CTAModel $call_to_action, array $extra_args = []): void { + // Always track conversion first + $call_to_action->track_conversion($extra_args); + + // Perform your action + $redirect_url = $call_to_action->get_setting('redirect_url'); + $this->safe_redirect($redirect_url); + exit; + } + + public function validate_settings(array $settings): \WP_Error|array|bool { + // Validate required fields + $validation = $this->validate_required_fields($settings); + if (is_wp_error($validation)) { + return $validation; + } + + // Custom validation + if (!filter_var($settings['redirect_url'], FILTER_VALIDATE_URL)) { + return new \WP_Error('invalid_url', __('Invalid URL', 'my-plugin')); + } + + return true; + } +} + +// Register CTA +add_filter('popup_maker/registered_call_to_actions', function($ctas) { + $ctas['my_custom_cta'] = new \MyPlugin\CallToAction\MyCustomCTA(); + return $ctas; +}); +``` + +### Asset Caching System + +```php +// Frontend assets (will be cached) +add_action('pum_enqueue_scripts', function() { + pum_enqueue_script( + 'my-plugin-frontend', + plugins_url('/js/frontend.js', __FILE__), + ['popup-maker-site'], + '1.0.0' + ); + + pum_enqueue_style( + 'my-plugin-frontend', + plugins_url('/css/frontend.css', __FILE__), + [], + '1.0.0' + ); +}); + +// Admin assets (not cached) +add_action('admin_enqueue_scripts', function() { + wp_enqueue_script( + 'my-plugin-admin', + plugins_url('/js/admin.js', __FILE__), + ['jquery'], + '1.0.0' + ); +}); +``` + +**AssetCache Details**: + +- Combined files: `pum-site-scripts.js` & `pum-site-styles.css` +- Location: `wp-content/uploads/pum/` +- Priority: 0=core, 1-5=extensions, 10=default, 15-20=per-popup +- Auto-regenerates on popup/theme changes + +## Frontend JavaScript APIs + +### Form Integration + +```javascript +( function ( $ ) { + $( document ).on( 'my_form_success', function ( event, formData ) { + const $form = $( event.target ); + + if ( window.PUM && window.PUM.integrations ) { + window.PUM.integrations.formSubmission( $form, { + formProvider: 'my-form-plugin', + formId: $form.data( 'form-id' ), + formInstanceId: $form.data( 'instance-id' ), + extras: { + formData: formData, + }, + } ); + } + } ); +} )( jQuery ); +``` + +### Custom Trigger (Frontend) + +```javascript +( function ( $, PUM ) { + PUM.hooks.addFilter( 'popupMaker.triggers', function ( triggers ) { + triggers.scroll_percentage = function ( settings, popup ) { + const percentage = parseInt( settings.percentage, 10 ); + + function checkScroll() { + const scrollPercent = Math.round( + ( $( window ).scrollTop() / + ( $( document ).height() - $( window ).height() ) ) * + 100 + ); + + if ( scrollPercent >= percentage ) { + PUM.open( popup.id ); + $( window ).off( 'scroll', checkScroll ); + } + } + + $( window ).on( 'scroll', checkScroll ); + }; + + return triggers; + } ); +} )( jQuery, window.PUM ); +``` + +## React/TypeScript APIs + +### Data Stores + +```typescript +import { + popupStore, + callToActionStore, + settingsStore, +} from '@popup-maker/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; + +// Popup Store +const { popups, popup } = useSelect( ( select ) => { + const store = select( popupStore ); + return { + popups: store.getPopups(), + popup: store.getPopup( 123 ), + isLoading: store.isResolving( 'getPopup', [ 123 ] ), + hasEdits: store.hasEdits( 123 ), + }; +} ); + +const { createPopup, updatePopup, deletePopup, undo, redo } = + useDispatch( popupStore ); + +// Settings Store (with custom hook) +import { useSettings } from '@popup-maker/core-data'; + +const { settings, getSetting, updateSettings, saveSettings } = useSettings(); +``` + +### Store Methods Reference + +**Popup Store** + +- Selectors: `getPopups()`, `getPopup(id)`, `hasEdits(id)`, `hasUndo(id)`, `hasRedo(id)` +- Actions: `createPopup()`, `updatePopup()`, `deletePopup()`, `editRecord()`, `saveEditedRecord()`, `undo()`, `redo()` + +**CTA Store** + +- Selectors: `getCallToActions()`, `getCallToAction(id)`, `isEditingCallToAction(id)` +- Actions: `createCallToAction()`, `updateCallToAction()`, `deleteCallToAction()` + +## Field System + +### Field Types + +- **Text**: `text`, `email`, `url`, `password`, `hidden` +- **Numeric**: `number`, `range`, `rangeslider`, `measure` +- **Selection**: `select`, `radio`, `checkbox`, `multicheck` +- **WordPress**: `postselect`, `taxonomyselect`, `objectselect` +- **Special**: `textarea`, `button`, `heading`, `html`, `hook`, `license_key` + +### Field Properties + +```php +[ + // Required + 'id' => 'field_id', + 'type' => 'text', + 'label' => __('Label', 'text-domain'), + + // Common + 'desc' => __('Help text', 'text-domain'), + 'std' => 'default value', + 'placeholder' => 'Enter value...', + 'required' => true, + + // Type-specific + 'min' => 0, // number, range, rangeslider + 'max' => 100, // number, range, rangeslider + 'step' => 5, // number, range, rangeslider + 'options' => [], // select, radio, multicheck + 'multiple' => true, // select, postselect + 'post_type' => 'post', // postselect + 'taxonomy' => 'tag', // taxonomyselect +] +``` + +### Extension Development Best Practices +- Use `pum_` prefixes for all public functions and hooks +- Use custom `\PopupMaker\{ExtensionName}\` namespace for classes/functions/hooks +- Check for Popup Maker existence before calling functions +- Follow WordPress coding standards (enforced by PHPCS) +- Use dependency injection over global access +- **Prioritize `@popup-maker/*` packages over `@wordpress/*` when available** (better optimization and consistency) +- Use Popup Maker's data stores (`popupStore`, `callToActionStore`) instead of WordPress core data when possible +- Use PUM.hooks system for frontend extensibility +- Follow `.cursor/rules/pm-best-practices.mdc` guidelines + +## Testing Strategy + +### E2E Tests +- Playwright tests in `tests/e2e/` +- Test popup functionality and admin workflows +- Run against local WordPress environment + +### Unit Tests +- Jest for JavaScript/TypeScript in `tests/unit/` +- PHPUnit for PHP in `tests/php/tests/` +- Mockery for PHP mocking + +### Code Quality +- PHPStan for static analysis +- PHPCS for WordPress coding standards +- ESLint for JavaScript/TypeScript +- Stylelint for CSS/SCSS + +### WordPress + +- Use `%i` for the table name in $wpdb prepared queries. + +### PHP + +DO NOT FORGET: +- Inline comments must end in full-stops, exclamation marks, or question marks (Squiz.Commenting.InlineComment.InvalidEndChar) +- **NEVER use strict PHP types on methods hooked to third-party actions/filters** - Use defensive validation instead + +#### Defensive Hook Callbacks + +When hooking methods to third-party WordPress actions/filters, avoid strict PHP type declarations. Third-party plugins can change their hook signatures causing fatal errors. + +**❌ Brittle (Will break on EDD/WC updates):** +```php +public function my_callback( int $id, string $status ): void { + // Fatal error if plugin passes wrong types +} +``` + +**βœ… Defensive (Survives plugin updates):** +```php +public function my_callback( $id, $status = '' ) { + // Validate inputs gracefully. + if ( ! is_numeric( $id ) || ! is_string( $status ) ) { + return; // Silent failure, no site breakage + } + + $id = (int) $id; // Safe conversion + // Rest of logic... +} +``` + +**Pattern for all third-party hooks:** +- Validate inputs at method start with early returns +- Use safe type conversion: `(int)`, `(string)`, `(array)` +- Provide sensible defaults for optional parameters +- Never assume third-party parameter structure/types + +#### PHP 7.4 Compatibility Requirements + +**CRITICAL**: All code must be compatible with PHP 7.4+ for production releases. + +**❌ Forbidden (PHP 8.0+ only):** +- Union types: `string|int`, `array|null`, `object|string` +- Mixed type: `mixed` (introduced in PHP 8.0) +- Named arguments: `func(param: $value)` +- Match expressions: `match($value) { ... }` + +**βœ… Safe for PHP 7.4:** +- Nullable types: `?string`, `?array`, `?object` +- Standard types: `string`, `int`, `array`, `object`, `bool` +- Elvis operator: `$value ?: 'default'` +- Null coalescing: `$value ?? 'default'` +- Null coalescing assignment: `$value ??= 'default'` (PHP 7.4+) + +**Emergency Fix Pattern:** +If union types are accidentally introduced and cause crashes: +```regex +# Remove all union return types +Find: \):\s*[a-zA-Z_\\]+(?:\|[a-zA-Z_\\]+)+ +Replace: ) + +# Remove all union parameter types +Find: ([,(]\s*)[a-zA-Z_\\]+(?:\|[a-zA-Z_\\]+)+(\s+)(\$\w+) +Replace: $1$3 +``` + +### JavaScript + +DO NOT FORGET: +- No `any` type, only use `unknown` for dynamic values outside of the type system, if we can find the type in another package, or define it reasonably for our own internal use, then we do that instead of `unknown` + +## Dependency Management + +### PHP Dependencies +- Composer with vendor prefixing via Strauss +- Dependencies compiled to `vendor-prefixed/` with `PopupMaker\Vendor\` namespace +- Run `composer install` to set up prefixed dependencies + +### JavaScript Dependencies +- NPM workspace for monorepo packages +- WordPress scripts for build tooling +- Custom webpack plugins for asset optimization + +## Legacy Considerations + +- `includes/` contains legacy functions for backwards compatibility +- Gradual migration from jQuery to React for admin interfaces +- `popmake_` prefixed functions are deprecated +- Asset cache system maintains compatibility with older extensions + +## Workflow Notes + +### Package Management Considerations +- When adding new packages, we have to update webpack config, tsconfigs, dependency extraction plugin package list AND Assets.php appropriately + +- Let services handle their own business logic - delegate with single method + calls rather than orchestrating multiple service operations when it makes since. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae295d954..288566af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,23 @@ ## Unreleased +**Security** + +- Security and resilience hardening based on continuous AI scanning. + +**Features** + +- Added an "Open Popup" click action to the Beaver Builder Button module, so you can open any popup on click without writing custom code. The chosen popup is automatically loaded on the page even if its display conditions wouldn't otherwise match. + +**Improvements** + +- Corrected the plural of "Call to Action" to "Calls to Action" across the admin. Thanks @swinggraphics. + **Fixes** +- Fixed Gravity Forms inside popups no longer submitting via AJAX (regression since 1.21.0), which broke text confirmations and Form Submission triggers after submit. +- Improved keyboard focus when closing popups: focus returns to the previously focused element, or to the top of the document when there wasn't one (auto-open popups) or it no longer exists. Thanks @swinggraphics. +- Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. - Fixed a bug where a popup could unexpectedly lose its triggers, conditions, and display settings β€” leaving only its theme β€” after saving, a plugin update, or when another plugin or page builder saved the popup. Saves that would erase an existing popup's settings are now prevented and report an error instead of silently discarding your configuration. ## v1.23.0 - 2026-06-28 diff --git a/assets/js/src/admin/general/vendor/select2.full.custom.js b/assets/js/src/admin/general/vendor/select2.full.custom.js index b970decf2..2db1ebfc1 100644 --- a/assets/js/src/admin/general/vendor/select2.full.custom.js +++ b/assets/js/src/admin/general/vendor/select2.full.custom.js @@ -6797,10 +6797,11 @@ setup: function () { if ( this.addEventListener ) { for ( var i = toBind.length; i; ) { - // Add passive option for wheel events + // Wheel handler calls preventDefault at scroll + // boundaries, so it must be non-passive. var options = toBind[ --i ] === 'wheel' - ? { passive: true } + ? { passive: false } : false; this.addEventListener( diff --git a/assets/js/src/admin/settings-page/pro-upgrade-flow.js b/assets/js/src/admin/settings-page/pro-upgrade-flow.js index ec283ebce..c923adbab 100644 --- a/assets/js/src/admin/settings-page/pro-upgrade-flow.js +++ b/assets/js/src/admin/settings-page/pro-upgrade-flow.js @@ -118,6 +118,8 @@ 'Invalid connection parameters. Please try again.' ); this.closePopup(); + // Re-enable the button so the user can retry. + $button.prop( 'disabled', false ); return; } diff --git a/assets/js/src/integration/beaverbuilder.js b/assets/js/src/integration/beaverbuilder.js index 09b9c2f53..36fbacaa8 100644 --- a/assets/js/src/integration/beaverbuilder.js +++ b/assets/js/src/integration/beaverbuilder.js @@ -8,13 +8,29 @@ // Hook into jQuery AJAX complete for all Beaver Builder forms. $( document ).on( 'ajaxComplete', function ( _event, xhr, settings ) { + const requestData = settings.data; + let params; + + if ( + typeof requestData === 'string' || + requestData instanceof URLSearchParams + ) { + params = new URLSearchParams( requestData ); + } else if ( + window.FormData && + requestData instanceof window.FormData + ) { + params = new URLSearchParams( requestData ); + } else { + return; + } + + const action = params.get( 'action' ); + // 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 ) + action !== 'fl_builder_email' && + action !== 'fl_builder_subscribe_form_submit' ) { return; } @@ -39,7 +55,6 @@ } // Extract form type and node ID from AJAX data. - const params = new URLSearchParams( settings.data ); const nodeId = params.get( 'node_id' ); if ( ! nodeId ) { @@ -57,7 +72,6 @@ } // Determine form type from action. - const action = params.get( 'action' ); let formType = 'unknown'; if ( action === 'fl_builder_email' ) { formType = 'contact'; diff --git a/assets/js/src/integration/newsletter.js b/assets/js/src/integration/newsletter.js index ba7279683..566afc067 100644 --- a/assets/js/src/integration/newsletter.js +++ b/assets/js/src/integration/newsletter.js @@ -156,18 +156,16 @@ $popup.find( FORM_SELECTORS ).each( function () { const $form = $( this ); - if ( $form.find( 'input[name="pum_form_popup_id"]' ).length ) { - return; + if ( ! $form.find( 'input[name="pum_form_popup_id"]' ).length ) { + $form.append( + $( '', { + type: 'hidden', + name: 'pum_form_popup_id', + value: popupId, + } ) + ); } - $form.append( - $( '', { - type: 'hidden', - name: 'pum_form_popup_id', - value: popupId, - } ) - ); - // Set up observer for this form. observeForm( this, popupId ); } ); diff --git a/assets/js/src/site/plugins/pum-accessibility.js b/assets/js/src/site/plugins/pum-accessibility.js index ef6f48edb..def972ac4 100644 --- a/assets/js/src/site/plugins/pum-accessibility.js +++ b/assets/js/src/site/plugins/pum-accessibility.js @@ -125,8 +125,31 @@ var PUM_Accessibility; .attr( 'aria-modal', 'false' ); // Accessibility: Focus back on the previously focused element. - if ( previouslyFocused !== undefined && previouslyFocused.length ) { + if ( + previouslyFocused !== undefined && + previouslyFocused.length && + previouslyFocused.is( ':visible' ) + ) { previouslyFocused.trigger( 'focus' ); + } else { + // Nothing had focus before the popup opened (ex. auto-open + // triggers), or that element is gone. Move focus to + // so the next Tab starts from the top of the document + // instead of the popup's position at the end of the page. + var $body = $( 'body' ), + hadTabindex = undefined !== $body.attr( 'tabindex' ); + + if ( ! hadTabindex ) { + $body.attr( 'tabindex', '-1' ); + } + + $body.trigger( 'focus' ); + + // Only remove the attribute we added; leave any + // pre-existing tabindex untouched. + if ( ! hadTabindex ) { + $body.removeAttr( 'tabindex' ); + } } // Accessibility: Clears the currentModal var. diff --git a/assets/js/src/site/plugins/pum-integrations.js b/assets/js/src/site/plugins/pum-integrations.js index d77d530f1..c01d0163d 100644 --- a/assets/js/src/site/plugins/pum-integrations.js +++ b/assets/js/src/site/plugins/pum-integrations.js @@ -67,8 +67,8 @@ .join( '_' ); if ( args.popup && args.popup.length ) { - args.popupId = PUM.getSetting( $popup, 'id' ); - $popup.trigger( 'pumConversion' ); + args.popupId = PUM.getSetting( args.popup, 'id' ); + args.popup.trigger( 'pumConversion' ); // Should this be here. It is the only thing not replicated by a new form trigger & cookie. // $popup.trigger('pumFormSuccess'); } diff --git a/assets/js/src/site/plugins/pum.js b/assets/js/src/site/plugins/pum.js index 218d4b2a8..f29bf1db8 100644 --- a/assets/js/src/site/plugins/pum.js +++ b/assets/js/src/site/plugins/pum.js @@ -188,7 +188,17 @@ document.createDocumentFragment().querySelector( selector ); return true; } catch ( e ) { - return false; + // Some selectors are invalid CSS but accepted by jQuery's + // engine (e.g. classes starting with a digit like + // `.2026-selector`) and have always worked as click triggers. + // jQuery does the actual matching, so defer to it before + // rejecting; truly malformed selectors still throw here. + try { + $( document.createDocumentFragment() ).find( selector ); + return true; + } catch ( err ) { + return false; + } } }, getClickTriggerSelector: function ( el, trigger_settings ) { diff --git a/bin/build-release.js b/bin/build-release.js index c7af705d3..e79a3de32 100755 --- a/bin/build-release.js +++ b/bin/build-release.js @@ -286,6 +286,15 @@ class PluginReleaseBuilder { const zipName = this.options.zipFileName || `${ this.pluginName }_${ this.version }.zip`; + + // Reject anything but a plain zip filename. The name can originate from a + // git tag (via --zip-name) and is used in shell/path operations below. + if ( ! /^[A-Za-z0-9._-]+\.zip$/.test( zipName ) ) { + throw new Error( + `Refusing unsafe zip name: "${ zipName }". Expected [A-Za-z0-9._-] and a .zip extension.` + ); + } + const zipPath = path.join( this.outputDir, zipName ); // Move build directory to plugin name @@ -301,10 +310,14 @@ class PluginReleaseBuilder { `Creating latest zip file` ); - // Copy (cp) to versioned zip file - this.executeCommand( - `cp "${ this.pluginName }-latest.zip" "${ zipName }"`, - `Creating versioned zip file` + // Copy to versioned zip file. Use fs (not a shell) so a crafted zip name + // cannot break out of the command. + if ( ! this.options.quiet ) { + console.log( 'Creating versioned zip file...' ); + } + fs.copyFileSync( + path.join( this.projectRoot, `${ this.pluginName }-latest.zip` ), + path.join( this.projectRoot, zipName ) ); // Move zip to output directory if different from project root diff --git a/bin/bump-and-publish-packages.js b/bin/bump-and-publish-packages.js index e5cba4414..dde9c7f4f 100755 --- a/bin/bump-and-publish-packages.js +++ b/bin/bump-and-publish-packages.js @@ -16,7 +16,31 @@ const fs = require( 'fs' ); const path = require( 'path' ); -const { execSync } = require( 'child_process' ); +const { execSync, execFileSync } = require( 'child_process' ); + +// Valid npm names/semvers can't contain shell metacharacters; validate before +// interpolating a package manifest's values into any shell command. +const NPM_NAME_RE = + /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +const SEMVER_RE = /^[0-9A-Za-z.+-]+$/; + +/** + * Assert a package name/version pair is safe to use in shell contexts. + * @param {string} name Package name. + * @param {string} version Package version. + * @return {void} + * @throws {Error} When either value is not a safe, expected token. + */ +function assertSafePackageIdentity( name, version ) { + if ( typeof name !== 'string' || ! NPM_NAME_RE.test( name ) ) { + throw new Error( `Refusing to proceed: unsafe package name "${ name }".` ); + } + if ( typeof version !== 'string' || ! SEMVER_RE.test( version ) ) { + throw new Error( + `Refusing to proceed: unsafe version "${ version }" for ${ name }.` + ); + } +} const minimist = require( 'minimist' ); const argv = minimist( process.argv.slice( 2 ) ); @@ -346,6 +370,9 @@ function main() { return; } + // Validate before any value reaches a shell command/script below. + updates.forEach( ( u ) => assertSafePackageIdentity( u.name, u.newVersion ) ); + // Commit changes log( '\nπŸ“ Committing changes...' ); const versionList = updates @@ -354,7 +381,20 @@ function main() { const commitMessage = `chore: bump package versions to ${ versionType }\n\n${ versionList }`; execCommand( 'git add packages/*/package.json' ); - execCommand( `git commit -m "${ commitMessage }"` ); + + // argv entry, not a shell string, so the message is never shell-interpreted. + if ( dryRun ) { + info( '[DRY RUN] Would execute: git commit -m ' ); + } else { + try { + execFileSync( 'git', [ 'commit', '-m', commitMessage ], { + encoding: 'utf8', + stdio: 'inherit', + } ); + } catch ( err ) { + throw new Error( `Command failed: git commit\n${ err.message }` ); + } + } success( 'βœ… Changes committed' ); @@ -406,8 +446,17 @@ function main() { ${ failedPackages .map( ( name ) => { const pkg = updates.find( ( u ) => u.name === name ); + const dir = path.basename( pkg.path ); + + // dir is written into a shell script; keep it to a safe set. + if ( ! /^[A-Za-z0-9._-]+$/.test( dir ) ) { + throw new Error( + `Refusing to write retry script: unsafe package directory "${ dir }".` + ); + } + return `echo "Publishing ${ name }..." -cd packages/${ path.basename( pkg.path ) } +cd packages/${ dir } pnpm publish --access public --no-git-checks cd ../..`; } ) diff --git a/bin/update-google-fonts.js b/bin/update-google-fonts.js index 580593273..4eb168332 100755 --- a/bin/update-google-fonts.js +++ b/bin/update-google-fonts.js @@ -11,9 +11,18 @@ const https = require( 'https' ); * and updates the local google-fonts.json file used by Popup Maker. */ -const API_KEY = 'AIzaSyCjkbFHtpK1fwdqTfACg_wZ9iJ0DtXjqrg'; +// Key comes from the environment, never source (it ships with the repo). +const API_KEY = process.env.GOOGLE_FONTS_API_KEY; const FONTS_JSON_PATH = path.join( __dirname, '../includes/google-fonts.json' ); +if ( ! API_KEY ) { + console.error( + '❌ Missing GOOGLE_FONTS_API_KEY environment variable. ' + + 'Set it to a restricted Google Fonts API key before running this script.' + ); + process.exit( 1 ); +} + /** * Fetch data from Google Fonts API */ diff --git a/classes/Admin/BlockEditor.php b/classes/Admin/BlockEditor.php index a5deb630b..4b02c49a8 100644 --- a/classes/Admin/BlockEditor.php +++ b/classes/Admin/BlockEditor.php @@ -145,7 +145,7 @@ public static function register_block_categories( $categories, $editor_context ) [ 'slug' => 'popup-maker', 'title' => __( 'Popup Maker', 'popup-maker' ), - 'icon' => pum_asset_url( 'mark.svg' ), + 'icon' => pum_asset_url( 'images/mark.svg' ), ], ] ); diff --git a/classes/Admin/Popups.php b/classes/Admin/Popups.php index 45f2f021d..28bfcf984 100644 --- a/classes/Admin/Popups.php +++ b/classes/Admin/Popups.php @@ -1278,7 +1278,7 @@ public static function render_analytics_meta_box() {
: -
: +
: 0 ) : ?>
diff --git a/classes/Admin/Settings.php b/classes/Admin/Settings.php index 2b9415b23..fff05381b 100644 --- a/classes/Admin/Settings.php +++ b/classes/Admin/Settings.php @@ -791,7 +791,9 @@ public static function field_go_pro_features() { // Detect integrations for contextual messaging. $integrations = PUM_Admin_Helpers::get_detected_integrations(); $has_woocommerce = isset( $integrations['woocommerce'] ); - $has_edd = isset( $integrations['edd'] ); + // The flat helper derives the slug from the label ("Easy Digital + // Downloads" => "easy_digital_downloads"); accept the legacy "edd" key too. + $has_edd = isset( $integrations['edd'] ) || isset( $integrations['easy_digital_downloads'] ); $has_lms = isset( $integrations['lifterlms'] ); $has_ecommerce = $has_woocommerce || $has_edd; // Check individual Pro+ addon status β€” show bar when platform detected but addon missing. @@ -891,6 +893,12 @@ public static function user_role_options() { */ public static function page() { + // Also reachable via the edit_posts-gated "Go Pro" submenu, and it dumps all + // settings into inline JS β€” enforce the settings capability here. + if ( ! current_user_can( \PopupMaker\plugin()->get_permission( 'manage_settings' ) ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'popup-maker' ), 403 ); + } + $settings = PUM_Utils_Options::get_all(); if ( empty( $settings ) ) { diff --git a/classes/Admin/Templates.php b/classes/Admin/Templates.php index 363d17311..c7f922d83 100644 --- a/classes/Admin/Templates.php +++ b/classes/Admin/Templates.php @@ -514,7 +514,7 @@ public static function custom_fields() { - + diff --git a/classes/AssetCache.php b/classes/AssetCache.php index 8a7a827a4..a19e266ba 100644 --- a/classes/AssetCache.php +++ b/classes/AssetCache.php @@ -834,7 +834,7 @@ private static function get_asset_contents( $src ) { $response = wp_remote_get( $src, [ - 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', false, $src ), + 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', true, $src ), 'timeout' => 15, ] ); @@ -857,6 +857,55 @@ private static function get_asset_contents( $src ) { return self::read_local_file( $src ); } + /** + * Canonicalize a path and confirm it stays within WP_CONTENT_DIR/ABSPATH. + * + * Blocks traversal and stream wrappers so a caller-supplied asset source can't + * read arbitrary files into the public cache. + * + * @param string $path Candidate filesystem path. + * + * @return string|false Canonical path within an allowed base, or false. + */ + private static function resolve_allowed_local_path( $path ) { + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + // Reject stream wrappers (phar://, file://, ...); local paths only. + if ( false !== strpos( $path, '://' ) ) { + return false; + } + + // realpath() resolves symlinks/.. and returns false when missing. + $real = realpath( $path ); + + if ( false === $real ) { + return false; + } + + $real = wp_normalize_path( $real ); + + $content_base = realpath( WP_CONTENT_DIR ); + $abspath_base = realpath( ABSPATH ); + + $allowed_bases = []; + if ( false !== $content_base ) { + $allowed_bases[] = trailingslashit( wp_normalize_path( $content_base ) ); + } + if ( false !== $abspath_base ) { + $allowed_bases[] = trailingslashit( wp_normalize_path( $abspath_base ) ); + } + + foreach ( $allowed_bases as $base ) { + if ( 0 === strpos( $real, $base ) ) { + return $real; + } + } + + return false; + } + /** * Read a local file path, returning false when missing or unreadable. * @@ -865,7 +914,11 @@ private static function get_asset_contents( $src ) { * @return string|false */ private static function read_local_file( $path ) { - $path = wp_normalize_path( $path ); + $path = self::resolve_allowed_local_path( $path ); + + if ( false === $path ) { + return false; + } if ( ! is_readable( $path ) || ! is_file( $path ) ) { return false; @@ -894,9 +947,12 @@ private static function url_to_local_path( $url ) { foreach ( $bases as $url_base => $path_base ) { if ( 0 === strpos( $url, $url_base ) ) { - $path = $path_base . substr( $url, strlen( $url_base ) ); + $candidate = $path_base . substr( $url, strlen( $url_base ) ); + + // Range-check to reject traversal outside the base. + $path = self::resolve_allowed_local_path( $candidate ); - return is_file( $path ) ? $path : false; + return ( $path && is_file( $path ) ) ? $path : false; } } diff --git a/classes/Base/Upgrade.php b/classes/Base/Upgrade.php index 97f371a21..c5e05e750 100644 --- a/classes/Base/Upgrade.php +++ b/classes/Base/Upgrade.php @@ -131,43 +131,28 @@ public function stream_run( $stream ) { /** * Return the stream. * - * If no stream is available it returns a mock object with no-op methods to prevent errors. + * If no stream is available it returns a mock object whose methods are all + * no-ops so callers can invoke stream methods without a fatal. * - * @return \PopupMaker\Services\UpgradeStream|(object{ - * send_event: Closure, - * send_error: Closure, - * send_data: Closure, - * update_status: Closure, - * update_task_status: Closure, - * start_upgrades: Closure, - * complete_upgrades: Closure, - * start_task: Closure, - * update_task_progress: Closure, - * complete_task: Closure - * }&\stdClass) Stream instance or mock object with no-op methods. + * @return \PopupMaker\Services\UpgradeStream|object Stream instance or no-op mock. */ public function stream() { - $noop = - /** - * No-op function for mock stream methods. - * - * @param mixed ...$args Variable arguments (ignored). - * - * @return void - */ - function () {}; - - return is_a( $this->stream, '\PopupMaker\Services\UpgradeStream' ) ? $this->stream : (object) [ - 'send_event' => $noop, - 'send_error' => $noop, - 'send_data' => $noop, - 'update_status' => $noop, - 'update_task_status' => $noop, - 'start_upgrades' => $noop, - 'complete_upgrades' => $noop, - 'start_task' => $noop, - 'update_task_progress' => $noop, - 'complete_task' => $noop, - ]; + if ( is_a( $this->stream, '\PopupMaker\Services\UpgradeStream' ) ) { + return $this->stream; + } + + // A stdClass with closure properties cannot be called as methods, so use + // an anonymous class whose __call swallows any stream method invocation. + return new class() { + /** + * No-op for any stream method call. + * + * @param string $name Method name. + * @param array $args Arguments (ignored). + * + * @return void + */ + public function __call( $name, $args ) {} + }; } } diff --git a/classes/Controllers/Admin.php b/classes/Controllers/Admin.php index dda861723..7c9da229a 100644 --- a/classes/Controllers/Admin.php +++ b/classes/Controllers/Admin.php @@ -58,7 +58,7 @@ public function filter_layout_vars( $vars ) { if ( is_string( $edit_ctas_cap ) && $edit_ctas_cap && current_user_can( $edit_ctas_cap ) ) { $vars['navTabs'][] = [ 'id' => 'call-to-actions', - 'title' => __( 'Call to Actions', 'popup-maker' ), + 'title' => __( 'Calls to Action', 'popup-maker' ), 'href' => admin_url( 'edit.php?post_type=popup&page=popup-maker-call-to-actions' ), ]; } diff --git a/classes/Controllers/Admin/CallToActions.php b/classes/Controllers/Admin/CallToActions.php index a2c0cd01c..5a66866f9 100644 --- a/classes/Controllers/Admin/CallToActions.php +++ b/classes/Controllers/Admin/CallToActions.php @@ -40,8 +40,8 @@ public function init() { public function register_page() { add_submenu_page( 'edit.php?post_type=popup', - __( 'Call to Actions', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $this->container->get_permission( 'edit_ctas' ), 'popup-maker-call-to-actions', [ $this, 'render_page' ] diff --git a/classes/Controllers/Assets.php b/classes/Controllers/Assets.php index 58d38ab14..add9ea2a8 100644 --- a/classes/Controllers/Assets.php +++ b/classes/Controllers/Assets.php @@ -90,20 +90,29 @@ public function get_packages() { 'styles' => true, 'deps' => [], 'varsName' => 'popupMakerBlockEditor', - 'vars' => [ - 'cta_types' => $this->container->get( 'cta_types' )->get_as_array(), - 'popups' => pum_get_all_popups(), - 'homeUrl' => home_url(), - 'previewNonce' => wp_create_nonce( 'popup-preview' ), - 'popupTriggerExcludedBlocks' => apply_filters( - 'pum_block_editor_popup_trigger_excluded_blocks', - [ - 'core/nextpage', - 'popup-maker/call-to-action', - 'popup-maker/call-to-actions', - ] - ), - ], + 'vars' => function () { + $vars = [ + 'cta_types' => $this->container->get( 'cta_types' )->get_as_array(), + 'popups' => pum_get_all_popups(), + 'homeUrl' => home_url(), + 'previewNonce' => wp_create_nonce( 'popup-preview' ), + 'popupTriggerExcludedBlocks' => apply_filters( + 'pum_block_editor_popup_trigger_excluded_blocks', + [ + 'core/nextpage', + 'popup-maker/call-to-action', + 'popup-maker/call-to-actions', + ] + ), + ]; + + // Template picker data is only needed in the popup editor itself. + if ( pum_is_popup_editor() ) { + $vars['templateLibrary'] = $this->container->get( 'template_library' )->get_editor_data(); + } + + return $vars; + }, ], 'block-library' => [ 'bundled' => false, @@ -113,7 +122,8 @@ public function get_packages() { 'varsName' => 'popupMakerBlockLibrary', 'vars' => function () { return [ - 'homeUrl' => home_url(), + 'homeUrl' => home_url(), + 'paramNames' => \PopupMaker\get_param_names(), ]; }, ], @@ -137,9 +147,22 @@ public function get_packages() { ], 'varsName' => 'popupMakerCoreData', 'vars' => function () { + $settings = \pum_get_options(); + + // Never expose raw license keys in page JS. This covers the Pro key + // (popup_maker_pro_license_key) and legacy addon keys (*_license_key). + // The license UIs have their own masked source of truth. + if ( is_array( $settings ) ) { + foreach ( array_keys( $settings ) as $setting_key ) { + if ( is_string( $setting_key ) && '_license_key' === substr( $setting_key, -12 ) ) { + unset( $settings[ $setting_key ] ); + } + } + } + return [ // TODO Migrate to use plugin('options')->get_all(); - 'currentSettings' => \pum_get_options(), + 'currentSettings' => $settings, ]; }, ], @@ -297,7 +320,8 @@ public function register_scripts() { } } - $footer = $package_data['head'] ?? true; + // 'head' => true means load in the head, i.e. NOT in the footer. + $footer = ! ( $package_data['head'] ?? false ); if ( $bundled ) { pum_register_script( $handle, $js_file, $js_deps, $meta['version'], $footer ); @@ -405,9 +429,9 @@ private function get_layout_vars() { $vars = apply_filters( 'popup_maker/layout_vars', [ - 'navTabs' => [], - 'supportMenuItems' => [], - 'showSupport' => true, + 'navTabs' => [], + 'supportMenuItems' => [], + 'showSupport' => true, ] ); diff --git a/classes/Controllers/CallToActions.php b/classes/Controllers/CallToActions.php index ca580e67d..ee99d1985 100644 --- a/classes/Controllers/CallToActions.php +++ b/classes/Controllers/CallToActions.php @@ -41,6 +41,12 @@ public function template_redirect() { $popup_id = \PopupMaker\get_param_value( 'popup_id', null, 'int' ); $notrack = \PopupMaker\get_param_value( 'notrack', false, 'bool' ); + // Only honor notrack for editors (preview links); otherwise anyone could + // append notrack=1 to suppress conversion tracking. + if ( $notrack && ! current_user_can( \PopupMaker\plugin()->get_permission( 'edit_popups' ) ) ) { + $notrack = false; + } + /** * Filter the CTA identifier before lookup. * @@ -71,6 +77,12 @@ public function template_redirect() { return; } + // The UUID->ID cache isn't invalidated on status change, so only act on + // published CTAs β€” a stale entry must not keep a disabled CTA live. + if ( 'publish' !== $call_to_action->status ) { + return; + } + /** * Filters the arguments passed to the CTA event. * @@ -182,8 +194,11 @@ private function handle_url_tracking( $popup_id, $notrack, $cta_args ) { \pum_track_conversion_event( $popup_id, $extra_args ); } + // remove_query_arg() builds from REQUEST_URI, which can be a protocol-relative + // URL (//evil.example/...); this same-page reload must stay on-site. $url = remove_query_arg( $cta_args ); - \PopupMaker\safe_redirect( $url ); + $url = wp_validate_redirect( $url, home_url( '/' ) ); + wp_safe_redirect( $url ); exit; } } diff --git a/classes/Controllers/Compatibility/Plugin/ACF.php b/classes/Controllers/Compatibility/Plugin/ACF.php index 9b6afcb0a..ad2a5c125 100644 --- a/classes/Controllers/Compatibility/Plugin/ACF.php +++ b/classes/Controllers/Compatibility/Plugin/ACF.php @@ -36,6 +36,13 @@ public function controller_enabled() { && apply_filters( 'popup_maker/enable_acf_shortcodes_in_popups', true ); } + /** + * The popup ID currently being rendered, used to scope ACF field access. + * + * @var int + */ + private $current_popup_id = 0; + /** * Init controller. * @@ -44,7 +51,7 @@ public function controller_enabled() { public function init() { // Bracket the popup content filter chain: enable before the shortcode // pass (runs at priority 11) and restore immediately after. - add_filter( 'pum_popup_content', [ $this, 'enable_field_access' ], 1 ); + add_filter( 'pum_popup_content', [ $this, 'enable_field_access' ], 1, 2 ); add_filter( 'pum_popup_content', [ $this, 'restore_field_access' ], 12 ); } @@ -52,12 +59,15 @@ public function init() { * Permit ACF shortcode field access for the duration of popup content * rendering. Paired with restore_field_access(). * - * @param string $content Popup content (passed through unchanged). + * @param string $content Popup content (passed through unchanged). + * @param int $popup_id ID of the popup being rendered. * * @return string */ - public function enable_field_access( $content ) { - add_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', '__return_false' ); + public function enable_field_access( $content, $popup_id = 0 ) { + $this->current_popup_id = (int) $popup_id; + + add_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', [ $this, 'allow_current_popup_fields' ], 10, 2 ); return $content; } @@ -71,8 +81,29 @@ public function enable_field_access( $content ) { * @return string */ public function restore_field_access( $content ) { - remove_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', '__return_false' ); + remove_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', [ $this, 'allow_current_popup_fields' ], 10 ); + + $this->current_popup_id = 0; return $content; } + + /** + * Relax ACF's non-public-post guard only for the popup currently rendering. + * + * Scoped to the popup's own ID so `[acf post_id=]` can't + * read fields from unrelated posts. + * + * @param bool $prevent_access Whether ACF should block field access. + * @param int|string $post_id Post the `[acf]` shortcode is reading from. + * + * @return bool + */ + public function allow_current_popup_fields( $prevent_access, $post_id = 0 ) { + if ( $this->current_popup_id && is_numeric( $post_id ) && (int) $post_id === $this->current_popup_id ) { + return false; + } + + return $prevent_access; + } } diff --git a/classes/Controllers/Debug.php b/classes/Controllers/Debug.php index 5f019ce29..edfd5eacd 100644 --- a/classes/Controllers/Debug.php +++ b/classes/Controllers/Debug.php @@ -35,11 +35,35 @@ public function init() { } /** - * Enqueue admin assets. + * Version of react-scan to load. Pinned so the Subresource Integrity hash + * below stays valid; bump both together when updating. + * + * @var string + */ + const REACT_SCAN_VERSION = '0.5.7'; + + /** + * SRI hash for react-scan REACT_SCAN_VERSION auto.global.js. + * + * @var string + */ + const REACT_SCAN_SRI = 'sha384-DDZCsimcjpG92OUulxf7DHi4rGS/fNIW7lC5DT8+5ftaTDiUKfzIq+pDTUbPjC86'; + + /** + * Print the react-scan dev profiler (pinned + SRI over HTTPS). */ public function admin_head() { - // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript ?> - ' . "\n", + esc_url( $src ), + esc_attr( self::REACT_SCAN_SRI ) + ); } } diff --git a/classes/Controllers/Frontend/Popups.php b/classes/Controllers/Frontend/Popups.php index dc8c9e95d..9d88d0abd 100644 --- a/classes/Controllers/Frontend/Popups.php +++ b/classes/Controllers/Frontend/Popups.php @@ -132,6 +132,28 @@ public function get_loaded_popups() { return $this->popups ?? []; } + /** + * Get the loaded popups shaped as a WP_Query. + * + * Back-compat helper for the legacy PUM_Site_Popups::get_loaded_popups() API, + * which returned a WP_Query rather than a plain array. + * + * @return \WP_Query + */ + public function get_loaded_popups_query() { + $popups = array_values( $this->get_loaded_popups() ); + + $query = new \WP_Query(); + $query->posts = $popups; + $query->post_count = count( $popups ); + $query->found_posts = $query->post_count; + $query->post = null; + + $query->rewind_posts(); + + return $query; + } + /** * Preloads popup, if enabled. * diff --git a/classes/Controllers/PostTypes.php b/classes/Controllers/PostTypes.php index 6d7c6b1c6..6ce376a56 100644 --- a/classes/Controllers/PostTypes.php +++ b/classes/Controllers/PostTypes.php @@ -61,6 +61,38 @@ public function get_type_key( $type ) { return $this->get_type_keys()[ $type ]; } + /** + * Full primitive-capability map for a map_meta_cap post type. + * + * Overriding only edit_posts/delete_posts leaves the rest (edit_published_posts, + * etc.) on the default `post` caps; mapping all of them keeps every status + * behind the same permission. + * + * @param string $permission Capability required for all operations. + * + * @return array + */ + private function full_capabilities( $permission ) { + return [ + // Meta caps (map_meta_cap maps these to the primitives below). + 'edit_post' => $permission, + 'read_post' => $permission, + 'delete_post' => $permission, + // Primitive caps. + 'create_posts' => $permission, + 'edit_posts' => $permission, + 'edit_others_posts' => $permission, + 'edit_published_posts' => $permission, + 'edit_private_posts' => $permission, + 'publish_posts' => $permission, + 'read_private_posts' => $permission, + 'delete_posts' => $permission, + 'delete_others_posts' => $permission, + 'delete_published_posts' => $permission, + 'delete_private_posts' => $permission, + ]; + } + /** * Register post types. * @@ -132,11 +164,7 @@ public function register_popup_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_popups' ), - 'edit_posts' => $this->container->get_permission( 'edit_popups' ), - 'delete_posts' => $this->container->get_permission( 'edit_popups' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_popups' ) ), ]; /** @@ -173,7 +201,6 @@ public function register_popup_post_type() { }, ] ); - } /** @@ -226,11 +253,7 @@ public function register_popup_theme_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_popup_themes' ), - 'edit_posts' => $this->container->get_permission( 'edit_popup_themes' ), - 'delete_posts' => $this->container->get_permission( 'edit_popup_themes' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_popup_themes' ) ), ]; /** @@ -264,14 +287,14 @@ public function register_cta_post_type() { $cta_labels = $this->post_type_labels( __( 'Call to Action', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $post_type_key ); $cta_args = [ 'label' => __( 'Call to Action', 'popup-maker' ), 'labels' => array_merge( $cta_labels, [ - 'all_items' => __( 'Call to Actions', 'popup-maker' ), + 'all_items' => __( 'Calls to Action', 'popup-maker' ), ] ), 'description' => '', // Basic. @@ -299,11 +322,7 @@ public function register_cta_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_ctas' ), - 'edit_posts' => $this->container->get_permission( 'edit_ctas' ), - 'delete_posts' => $this->container->get_permission( 'edit_ctas' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_ctas' ) ), ]; /** @@ -373,15 +392,12 @@ public function register_popup_tag_tax() { $tag_labels = (array) get_taxonomy_labels( get_taxonomy( 'post_tag' ) ); - $tag_args = apply_filters( - 'popmake_tag_args', - [ - 'hierarchical' => false, - 'labels' => $tag_labels, - 'public' => false, - 'show_ui' => true, - ] - ); + $tag_args = [ + 'hierarchical' => false, + 'labels' => $tag_labels, + 'public' => false, + 'show_ui' => true, + ]; /** * Filter: popup_maker/popup_tag_tax_args diff --git a/classes/Controllers/RestAPI.php b/classes/Controllers/RestAPI.php index ba5c7126c..15a0c0b33 100644 --- a/classes/Controllers/RestAPI.php +++ b/classes/Controllers/RestAPI.php @@ -514,6 +514,16 @@ public function register_cta_rest_fields() { return get_post_status( $obj['id'] ); }, 'update_callback' => function ( $value, $obj ) { + // Trashing is a delete action; the field only checks edit_ctas, so + // require delete rights for this object before allowing it. + if ( 'trash' === $value && ! current_user_can( 'delete_post', $obj->ID ) ) { + return new \WP_Error( + 'rest_cannot_delete', + __( 'You do not have permission to trash this call to action.', 'popup-maker' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + wp_update_post( [ 'ID' => $obj->ID, 'post_status' => $value, @@ -571,7 +581,7 @@ public function register_cta_rest_fields() { 'description' => __( 'Stats for this CTA.', 'popup-maker' ), 'type' => 'object', 'properties' => [ - 'conversion' => [ + 'conversions' => [ 'type' => 'integer', 'minimum' => 0, ], diff --git a/classes/Controllers/TemplateLibrary.php b/classes/Controllers/TemplateLibrary.php new file mode 100644 index 000000000..0da4595d4 --- /dev/null +++ b/classes/Controllers/TemplateLibrary.php @@ -0,0 +1,78 @@ +container->get( 'template_library' ); + + register_block_pattern_category( 'popup-maker-templates', [ + 'label' => __( 'Popup Templates', 'popup-maker' ), + 'description' => __( 'Ready-made popup layouts from Popup Maker.', 'popup-maker' ), + ] ); + + foreach ( $service->get_categories() as $slug => $label ) { + register_block_pattern_category( 'popup-maker-' . $slug, [ + /* translators: %s: Template category label. */ + 'label' => sprintf( _x( 'Popup: %s', 'Block pattern category label', 'popup-maker' ), $label ), + ] ); + } + + foreach ( $service->get_templates() as $template ) { + if ( ! $service->is_insertable( $template ) ) { + continue; + } + + register_block_pattern( 'popup-maker/' . $template['slug'], [ + 'title' => $template['name'], + 'description' => $template['description'], + 'content' => $template['content'], + 'categories' => [ 'popup-maker-templates', 'popup-maker-' . $template['category'] ], + 'keywords' => $template['keywords'], + 'postTypes' => [ 'popup' ], + 'viewportWidth' => $template['viewport_width'], + 'inserter' => true, + ] ); + } + } +} diff --git a/classes/Controllers/WP/I18n.php b/classes/Controllers/WP/I18n.php index 7ae790866..b615b6c2c 100644 --- a/classes/Controllers/WP/I18n.php +++ b/classes/Controllers/WP/I18n.php @@ -33,6 +33,9 @@ public function init() { * @return void */ public function load_textdomain() { - load_plugin_textdomain( $this->container['text_domain'], false, $this->container->get_path( 'languages' ) ); + // The third argument must be relative to WP_PLUGIN_DIR, not an absolute path. + $languages_rel_path = dirname( $this->container->get( 'basename' ) ) . '/languages'; + + load_plugin_textdomain( $this->container['text_domain'], false, $languages_rel_path ); } } diff --git a/classes/Integration/Builder/BeaverBuilder.php b/classes/Integration/Builder/BeaverBuilder.php new file mode 100644 index 000000000..c46d2b6f9 --- /dev/null +++ b/classes/Integration/Builder/BeaverBuilder.php @@ -0,0 +1,188 @@ + [ 'pum_open_popup' ], + ]; + + // The popup selector itself. + $fields['pum_open_popup'] = [ + 'type' => 'select', + 'label' => __( 'Popup', 'popup-maker' ), + 'default' => '', + 'options' => $this->get_popup_options(), + 'help' => __( 'Open the selected popup when this button is clicked.', 'popup-maker' ), + ]; + + return $form; + } + + /** + * Append the Popup Maker trigger class to the button when a popup is set. + * + * @param array $attrs Module node attributes ('class' is an array). + * @param object $module Beaver Builder module instance. + * + * @return array + */ + public function add_trigger_class( $attrs, $module ) { + if ( ! isset( $module->slug ) || 'button' !== $module->slug ) { + return $attrs; + } + + // Only when the Open Popup click action is selected. + if ( ! isset( $module->settings->click_action ) || 'popup' !== $module->settings->click_action ) { + return $attrs; + } + + $popup_id = isset( $module->settings->pum_open_popup ) ? absint( $module->settings->pum_open_popup ) : 0; + + if ( $popup_id <= 0 ) { + return $attrs; + } + + if ( ! isset( $attrs['class'] ) || ! is_array( $attrs['class'] ) ) { + $attrs['class'] = isset( $attrs['class'] ) ? (array) $attrs['class'] : []; + } + + $attrs['class'][] = 'popmake-' . $popup_id; + + // Force the referenced popup to load on this page regardless of its own + // display conditions β€” otherwise the button would render but the popup + // it targets might not be enqueued. Skipped in the builder UI/admin. + // Mirrors the popup-trigger shortcode. maybe_preload_popup() still + // respects the popup's enabled state. + if ( ! is_admin() && ! ( class_exists( 'FLBuilderModel' ) && FLBuilderModel::is_builder_active() ) ) { + \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->maybe_preload_popup( $popup_id ); + } + + return $attrs; + } + + /** + * Build the popup options list for the select field. + * + * @return array + */ + private function get_popup_options() { + $options = [ + '' => __( 'β€” None β€”', 'popup-maker' ), + ]; + + $popups = pum_get_all_popups(); + + if ( empty( $popups ) || ! is_array( $popups ) ) { + return $options; + } + + foreach ( $popups as $popup ) { + if ( ! pum_is_popup( $popup ) ) { + continue; + } + + // Use the post title (the popup's admin name); get_title() reads the + // separate popup_title meta, which is usually empty. + $title = $popup->post_title; + + if ( '' === trim( (string) $title ) ) { + $title = __( '(no title)', 'popup-maker' ); + } + + $options[ $popup->ID ] = sprintf( + /* translators: 1: popup title, 2: popup ID. */ + __( '%1$s (ID: %2$d)', 'popup-maker' ), + $title, + $popup->ID + ); + } + + return $options; + } +} diff --git a/classes/Integration/Form/KaliForms.php b/classes/Integration/Form/KaliForms.php index fd1f65a56..4b060cbf4 100644 --- a/classes/Integration/Form/KaliForms.php +++ b/classes/Integration/Form/KaliForms.php @@ -128,8 +128,11 @@ public function on_success( $args ) { $popup_id = $this->get_popup_id(); - if ( $popup_id ) { + // popup_id comes from a forgeable public form field; only count real popups. + if ( $popup_id && pum_is_popup( $popup_id ) ) { $this->increase_conversion( $popup_id ); + } else { + $popup_id = false; } pum_integrated_form_submission( @@ -152,14 +155,14 @@ public function get_popup_id() { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw_data = wp_unslash( $_POST['data'] ); - // Handle JSON data first. - $json_data = json_decode( $raw_data, true ); - if ( is_array( $json_data ) && isset( $json_data['pum_form_popup_id'] ) ) { - return absint( $json_data['pum_form_popup_id'] ); - } - - // Parse the data if it's a URL-encoded string. + // data[]=x arrives as an array and would fatal in json_decode/parse_str. if ( is_string( $raw_data ) ) { + $json_data = json_decode( $raw_data, true ); + if ( is_array( $json_data ) && isset( $json_data['pum_form_popup_id'] ) ) { + return absint( $json_data['pum_form_popup_id'] ); + } + + // Parse the data if it's a URL-encoded string. // Parse first to preserve URL encoding, then sanitize individual values. parse_str( $raw_data, $data ); diff --git a/classes/Integrations.php b/classes/Integrations.php index 7cf03bb0e..30d2c1a90 100644 --- a/classes/Integrations.php +++ b/classes/Integrations.php @@ -58,6 +58,7 @@ public static function init() { // Page Builders. 'kingcomposer' => new PUM_Integration_Builder_KingComposer(), 'visualcomposer' => new PUM_Integration_Builder_VisualComposer(), + 'beaverbuilder_button' => new PUM_Integration_Builder_BeaverBuilder(), // 'bricks' => new PUM_Integration_Builder_Bricks(), ] ); diff --git a/classes/Licensing.php b/classes/Licensing.php index 6dbade8a2..e57de1646 100644 --- a/classes/Licensing.php +++ b/classes/Licensing.php @@ -92,7 +92,21 @@ public static function get_status_messages( $license = null, $key = '', $product // Prefer a stored error message from a recent API response. if ( false === $license->success && ! empty( $license->error_message ) ) { - $messages[] = $license->error_message; + // error_message is untrusted (remote API) and the template renders + // messages unescaped; allow only safe inline markup. + $messages[] = wp_kses( + $license->error_message, + [ + 'a' => [ + 'href' => true, + 'target' => true, + 'rel' => true, + ], + 'strong' => [], + 'em' => [], + 'br' => [], + ] + ); return $messages; } diff --git a/classes/Models/CallToAction.php b/classes/Models/CallToAction.php index 29164c2e7..06cf22144 100644 --- a/classes/Models/CallToAction.php +++ b/classes/Models/CallToAction.php @@ -219,8 +219,10 @@ public function get_description() { * @return string */ public function generate_url( $base_url = '', $extra_args = [] ) { + $cta_param = \PopupMaker\get_param_name( 'cta' ); + $args = wp_parse_args( $extra_args, [ - 'cta' => $this->get_uuid(), + $cta_param => $this->get_uuid(), ] ); return \add_query_arg( $args, $base_url ); diff --git a/classes/Plugin/Container.php b/classes/Plugin/Container.php index 782139de9..85759729b 100644 --- a/classes/Plugin/Container.php +++ b/classes/Plugin/Container.php @@ -99,7 +99,9 @@ public function get_controller( $name ) { $controller = $this->controllers->get( $name ); - if ( $controller instanceof Controller ) { + // Match the registration check so controllers that implement the interface + // without extending Base\Controller remain retrievable. + if ( $controller instanceof \PopupMaker\Interfaces\Controller ) { return $controller; } diff --git a/classes/Plugin/Core.php b/classes/Plugin/Core.php index f87979384..328fdf0a7 100644 --- a/classes/Plugin/Core.php +++ b/classes/Plugin/Core.php @@ -40,16 +40,17 @@ public function __construct( $config ) { */ protected function registered_controllers() { return [ - 'Admin' => new \PopupMaker\Controllers\Admin( $this ), - 'Assets' => new \PopupMaker\Controllers\Assets( $this ), - 'CallToActions' => new \PopupMaker\Controllers\CallToActions( $this ), - 'Compatibility' => new \PopupMaker\Controllers\Compatibility( $this ), - 'Debug' => new \PopupMaker\Controllers\Debug( $this ), - 'PostTypes' => new \PopupMaker\Controllers\PostTypes( $this ), - 'RestAPI' => new \PopupMaker\Controllers\RestAPI( $this ), - 'Upgrades' => new \PopupMaker\Controllers\Upgrades( $this ), - 'WP' => new \PopupMaker\Controllers\WP( $this ), - 'Frontend' => new \PopupMaker\Controllers\Frontend( $this ), + 'Admin' => new \PopupMaker\Controllers\Admin( $this ), + 'Assets' => new \PopupMaker\Controllers\Assets( $this ), + 'CallToActions' => new \PopupMaker\Controllers\CallToActions( $this ), + 'Compatibility' => new \PopupMaker\Controllers\Compatibility( $this ), + 'Debug' => new \PopupMaker\Controllers\Debug( $this ), + 'PostTypes' => new \PopupMaker\Controllers\PostTypes( $this ), + 'RestAPI' => new \PopupMaker\Controllers\RestAPI( $this ), + 'TemplateLibrary' => new \PopupMaker\Controllers\TemplateLibrary( $this ), + 'Upgrades' => new \PopupMaker\Controllers\Upgrades( $this ), + 'WP' => new \PopupMaker\Controllers\WP( $this ), + 'Frontend' => new \PopupMaker\Controllers\Frontend( $this ), // 'BlockEditor' => new \PopupMaker\Controllers\BlockEditor( $this ), // 'Frontend' => new \PopupMaker\Controllers\Frontend( $this ), // 'Shortcodes' => new \PopupMaker\Controllers\Shortcodes( $this ), @@ -280,6 +281,18 @@ function ( $container ) { // } // ); + $this->set( + 'template_library', + /** + * Get popup template library. + * + * @return \PopupMaker\Services\TemplateLibrary + */ + function ( $container ) { + return new \PopupMaker\Services\TemplateLibrary( $container ); + } + ); + $this->set( 'globals', /** diff --git a/classes/Previews.php b/classes/Previews.php index e087315bc..f198410ef 100644 --- a/classes/Previews.php +++ b/classes/Previews.php @@ -87,6 +87,12 @@ private static function is_previewing_popup( $popup_id = 0 ) { public static function force_load_preview() { $preview_id = static::get_popup_preview(); + // The preview nonce is shared across block-editor screens; require edit + // access to this specific popup before force-loading draft/private content. + if ( ! $preview_id || ! current_user_can( 'edit_post', $preview_id ) ) { + return; + } + $popup = pum_get_popup( $preview_id ); if ( $popup->is_valid() && $preview_id === $popup->ID ) { diff --git a/classes/RestAPI/ObjectSearch.php b/classes/RestAPI/ObjectSearch.php index c46662103..feaa5dd82 100644 --- a/classes/RestAPI/ObjectSearch.php +++ b/classes/RestAPI/ObjectSearch.php @@ -112,12 +112,36 @@ public function search_objects( $request ) { switch ( $object_type ) { case 'post_type': $post_type = $request->get_param( 'object_key' ) ?: 'post'; - $results = $this->search_post_type( $post_type, $request, $included, $excluded ); + + // object_key can target any post type; enforce its own edit cap. + $post_type_object = get_post_type_object( $post_type ); + + if ( ! $post_type_object || ! current_user_can( $post_type_object->cap->edit_posts ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to search this object type.', 'popup-maker' ), + [ 'status' => 403 ] + ); + } + + $results = $this->search_post_type( $post_type, $request, $included, $excluded ); break; case 'taxonomy': $taxonomy = $request->get_param( 'object_key' ) ?: 'category'; - $results = $this->search_taxonomy( $taxonomy, $request, $included, $excluded ); + + // object_key can target any taxonomy; enforce its own assign cap. + $taxonomy_object = get_taxonomy( $taxonomy ); + + if ( ! $taxonomy_object || ! current_user_can( $taxonomy_object->cap->assign_terms ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to search this taxonomy.', 'popup-maker' ), + [ 'status' => 403 ] + ); + } + + $results = $this->search_taxonomy( $taxonomy, $request, $included, $excluded ); break; case 'user': diff --git a/classes/Services/License.php b/classes/Services/License.php index d3e971643..99e523808 100644 --- a/classes/Services/License.php +++ b/classes/Services/License.php @@ -528,7 +528,13 @@ private function update_license_key( string $key ): bool { * * @return bool */ - private function update_license_status( array $license_status ): bool { + private function update_license_status( ?array $license_status ): bool { + // Callers may pass null/empty on a failed or empty API response; never + // fatal the typed setter and never clobber stored status with nothing. + if ( empty( $license_status ) ) { + return false; + } + $license_data = $this->get_license_data(); $previous_status = isset( $license_data['status'] ) ? $license_data['status'] : []; @@ -597,6 +603,11 @@ public function refresh_license_status(): bool { $status = null; } + // A transient failure must not overwrite a previously valid status. + if ( empty( $status ) ) { + return false; + } + return $this->update_license_status( $status ); } @@ -724,13 +735,13 @@ public function activate_license(): bool { public function deactivate_license(): bool { $license_status = $this->api_call( 'deactivate_license' ); - $this->update_license_status( $license_status ); - if ( empty( $license_status ) ) { return false; } - $succeeded = 'deactivated' === $license_status['license']; + $this->update_license_status( $license_status ); + + $succeeded = isset( $license_status['license'] ) && 'deactivated' === $license_status['license']; /** * Fires when license is activated. diff --git a/classes/Services/TemplateLibrary.php b/classes/Services/TemplateLibrary.php new file mode 100644 index 000000000..22c2ccb4a --- /dev/null +++ b/classes/Services/TemplateLibrary.php @@ -0,0 +1,384 @@ +>|null + */ + private $templates; + + /** + * Initialize the service. + * + * @param \PopupMaker\Plugin\Core $container Plugin container. + */ + public function __construct( $container ) { + $this->container = $container; + } + + /** + * Get all registered template categories. + * + * @return array Category slug => label. + */ + public function get_categories() { + $categories = [ + 'subscribe' => __( 'Subscribe & Opt-in', 'popup-maker' ), + 'sales-promotions' => __( 'Sales & Promotions', 'popup-maker' ), + 'announcements' => __( 'Announcements', 'popup-maker' ), + 'lead-capture' => __( 'Lead Capture', 'popup-maker' ), + 'engagement' => __( 'Engagement', 'popup-maker' ), + 'compliance' => __( 'Compliance', 'popup-maker' ), + 'ecommerce' => __( 'Ecommerce', 'popup-maker' ), + ]; + + /** + * Filter the registered popup template categories. + * + * @param array $categories Category slug => label. + * + * @since X.X.X + */ + return apply_filters( 'popup_maker/popup_template_categories', $categories ); + } + + /** + * Get all registered popup templates keyed by slug. + * + * @return array> + */ + public function get_templates() { + if ( null !== $this->templates ) { + return $this->templates; + } + + $templates = $this->load_built_in_templates(); + + /** + * Filter the registered popup templates. + * + * Pro & addons register their templates here. Registrations that + * include `content` replace core placeholder (teaser) entries of + * the same slug. + * + * @param array> $templates Templates keyed by slug. + * + * @since X.X.X + */ + $templates = apply_filters( 'popup_maker/popup_templates', $templates ); + + // Backfill teasers for premium templates that nothing registered. + foreach ( $this->get_teaser_templates() as $slug => $teaser ) { + if ( ! isset( $templates[ $slug ] ) ) { + $templates[ $slug ] = $teaser; + } + } + + $normalized = []; + + foreach ( $templates as $slug => $template ) { + $template = $this->normalize_template( is_string( $slug ) ? $slug : '', $template ); + + if ( false !== $template ) { + $normalized[ $template['slug'] ] = $template; + } + } + + $this->templates = $normalized; + + return $this->templates; + } + + /** + * Get a single template by slug. + * + * @param string $slug Template slug. + * + * @return array|null + */ + public function get_template( $slug ) { + $templates = $this->get_templates(); + + return isset( $templates[ $slug ] ) ? $templates[ $slug ] : null; + } + + /** + * Whether a template has insertable content. + * + * @param array $template Template definition. + * + * @return bool + */ + public function is_insertable( $template ) { + return ! empty( $template['content'] ) && is_string( $template['content'] ); + } + + /** + * Get template data formatted for the block editor picker. + * + * @return array + */ + public function get_editor_data() { + $templates = []; + + foreach ( $this->get_templates() as $template ) { + $insertable = $this->is_insertable( $template ); + + $templates[] = [ + 'slug' => $template['slug'], + 'name' => $template['name'], + 'description' => $template['description'], + 'category' => $template['category'], + 'tier' => $template['tier'], + 'keywords' => $template['keywords'], + 'viewportWidth' => $template['viewport_width'], + 'content' => $insertable ? $template['content'] : '', + 'recommended' => $template['recommended'], + 'proRequired' => ! $insertable && 'free' !== $template['tier'], + 'upgradeUrl' => $template['upgrade_url'], + ]; + } + + return [ + 'templates' => $templates, + 'categories' => $this->get_categories(), + 'i10n' => [ + 'proLabel' => _x( 'Pro', 'Template picker pro tier badge', 'popup-maker' ), + 'proPlusLabel' => _x( 'Pro+', 'Template picker pro plus tier badge', 'popup-maker' ), + ], + ]; + } + + /** + * Load built-in template definitions from disk. + * + * Each file in `includes/popup-templates/` returns a full template + * definition array. + * + * @return array> + */ + private function load_built_in_templates() { + $templates = []; + + $path = trailingslashit( $this->container->get_path( 'includes/popup-templates' ) ); + $files = glob( $path . '*.php' ); + + if ( false === $files ) { + return $templates; + } + + foreach ( $files as $file ) { + $template = include $file; + + if ( is_array( $template ) && ! empty( $template['slug'] ) ) { + $templates[ $template['slug'] ] = $template; + } + } + + return $templates; + } + + /** + * Normalize a template definition. + * + * @param string $slug Registration key, used as fallback slug. + * @param array $template Raw template definition. + * + * @return array|false Normalized template or false when invalid. + */ + private function normalize_template( $slug, $template ) { + if ( ! is_array( $template ) ) { + return false; + } + + $template = wp_parse_args( $template, [ + 'slug' => $slug, + 'name' => '', + 'description' => '', + 'category' => 'engagement', + 'tier' => 'free', + 'source' => 'popup-maker', + 'keywords' => [], + 'viewport_width' => 480, + 'content' => '', + 'recommended' => [], + 'upgrade_url' => '', + ] ); + + if ( empty( $template['slug'] ) || empty( $template['name'] ) ) { + return false; + } + + if ( ! isset( $this->get_categories()[ $template['category'] ] ) ) { + $template['category'] = 'engagement'; + } + + if ( ! in_array( $template['tier'], [ 'free', 'pro', 'pro_plus' ], true ) ) { + $template['tier'] = 'free'; + } + + $template['recommended'] = wp_parse_args( is_array( $template['recommended'] ) ? $template['recommended'] : [], [ + 'triggers' => [], + 'cookies' => [], + 'notes' => '', + ] ); + + return $template; + } + + /** + * Teaser entries for premium templates. + * + * Shown locked in the template picker when Pro (or the required Pro+ + * addon) has not registered the real template. Mirrors the preview + * conventions used by \PUM_Upsell for triggers & conditions. + * + * @return array> + */ + private function get_teaser_templates() { + $teasers = [ + // Pro tier. + 'exit-intent-offer' => [ + 'name' => __( 'Exit Intent Offer', 'popup-maker' ), + 'description' => __( 'Catch abandoning visitors with a last-chance discount before they leave.', 'popup-maker' ), + 'category' => 'sales-promotions', + 'tier' => 'pro', + ], + 'deadline-sale' => [ + 'name' => __( 'Deadline Sale', 'popup-maker' ), + 'description' => __( 'High-urgency sale layout built around a hard deadline.', 'popup-maker' ), + 'category' => 'sales-promotions', + 'tier' => 'pro', + ], + 'holiday-sale' => [ + 'name' => __( 'Holiday Sale', 'popup-maker' ), + 'description' => __( 'Seasonal promotion layout ready for Black Friday & holiday campaigns.', 'popup-maker' ), + 'category' => 'sales-promotions', + 'tier' => 'pro', + ], + 'yes-no-multistep' => [ + 'name' => __( 'Yes / No Multistep', 'popup-maker' ), + 'description' => __( 'Two-step engagement popup that qualifies visitors before the offer.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'pro', + ], + 'webinar-registration' => [ + 'name' => __( 'Webinar Registration', 'popup-maker' ), + 'description' => __( 'Promote your next live event and drive registrations.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'pro', + ], + 'scroll-content-upgrade' => [ + 'name' => __( 'Scroll Content Upgrade', 'popup-maker' ), + 'description' => __( 'Offer a bonus resource to engaged readers as they scroll.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'pro', + ], + 'nps-feedback' => [ + 'name' => __( 'NPS Feedback', 'popup-maker' ), + 'description' => __( 'Ask visitors how likely they are to recommend you.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'pro', + ], + 'loyalty-signup' => [ + 'name' => __( 'Loyalty Program Signup', 'popup-maker' ), + 'description' => __( 'Invite repeat visitors to join your rewards program.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'pro', + ], + 'referral-invite' => [ + 'name' => __( 'Referral Invite', 'popup-maker' ), + 'description' => __( 'Turn happy customers into advocates with a share-and-earn invite.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'pro', + ], + // Pro+ (ecommerce addon) tier. + 'cart-abandonment-offer' => [ + 'name' => __( 'Cart Abandonment Offer', 'popup-maker' ), + 'description' => __( 'Recover abandoning shoppers with a targeted discount at exit.', 'popup-maker' ), + 'category' => 'ecommerce', + 'tier' => 'pro_plus', + ], + 'free-shipping-threshold' => [ + 'name' => __( 'Free Shipping Threshold', 'popup-maker' ), + 'description' => __( 'Nudge shoppers to add more to their cart to unlock free shipping.', 'popup-maker' ), + 'category' => 'ecommerce', + 'tier' => 'pro_plus', + ], + 'product-cross-sell' => [ + 'name' => __( 'Product Cross-sell', 'popup-maker' ), + 'description' => __( 'Recommend a complementary product with a one-click add to cart.', 'popup-maker' ), + 'category' => 'ecommerce', + 'tier' => 'pro_plus', + ], + 'back-in-stock-notify' => [ + 'name' => __( 'Back in Stock Notify', 'popup-maker' ), + 'description' => __( 'Capture demand for sold-out products with restock alerts.', 'popup-maker' ), + 'category' => 'ecommerce', + 'tier' => 'pro_plus', + ], + 'first-purchase-discount' => [ + 'name' => __( 'First Purchase Discount', 'popup-maker' ), + 'description' => __( 'Convert first-time visitors into customers with a welcome discount.', 'popup-maker' ), + 'category' => 'ecommerce', + 'tier' => 'pro_plus', + ], + ]; + + foreach ( $teasers as $slug => $teaser ) { + $teasers[ $slug ]['slug'] = $slug; + $teasers[ $slug ]['upgrade_url'] = $this->get_upgrade_url( $slug, $teaser['tier'] ); + } + + return $teasers; + } + + /** + * Build an upgrade URL for a locked template. + * + * @param string $slug Template slug. + * @param string $tier Template tier. + * + * @return string + */ + private function get_upgrade_url( $slug, $tier ) { + $url = 'pro_plus' === $tier + ? 'https://wppopupmaker.com/addons/ecommerce-popups/' + : 'https://wppopupmaker.com/pricing/'; + + return add_query_arg( [ + 'utm_campaign' => 'upgrade-to-pro', + 'utm_source' => 'popup-template-library', + 'utm_medium' => 'plugin-ui', + 'utm_content' => $slug, + ], $url ); + } +} diff --git a/classes/Services/UpgradeStream.php b/classes/Services/UpgradeStream.php index 9067393b9..997889dd8 100644 --- a/classes/Services/UpgradeStream.php +++ b/classes/Services/UpgradeStream.php @@ -90,6 +90,14 @@ public function update_task_status( $task_status ) { * @return void */ public function send_event( $event, $data = [] ) { + // Inherited callers such as send_error() may pass a string or scalar; + // normalize to an array so the offset writes below never hit a scalar. + if ( ! is_array( $data ) ) { + $data = ( null === $data || '' === $data ) + ? [] + : [ 'message' => is_scalar( $data ) ? (string) $data : \wp_json_encode( $data ) ]; + } + // Always send the status. $data['status'] = $this->status; diff --git a/classes/Shortcode/CallToAction.php b/classes/Shortcode/CallToAction.php index 689e021d7..11dcf115b 100644 --- a/classes/Shortcode/CallToAction.php +++ b/classes/Shortcode/CallToAction.php @@ -189,6 +189,12 @@ public function handler( $atts, $content = null ) { return 'Missing Call To Action'; } + // This shortcode runs in any post, and get_cta_by_id() ignores status β€” + // only render published CTAs so authors can't surface non-public UUIDs. + if ( 'publish' !== $cta->status ) { + return 'Missing Call To Action'; + } + $type = $cta->get_setting( 'type', 'link' ); $uuid = $cta->get_uuid(); diff --git a/classes/Site/Popups.php b/classes/Site/Popups.php index 532cc6bc5..6db8e7ab7 100644 --- a/classes/Site/Popups.php +++ b/classes/Site/Popups.php @@ -53,7 +53,14 @@ public static function init() { * @deprecated 1.8.0 Use pum()->current_popup directly or PopupMaker\set_current_popup() */ public static function current_popup( $new_popup = false ) { - return \PopupMaker\get_current_popup(); + global $popup; + + if ( false !== $new_popup ) { + \PopupMaker\set_current_popup( $new_popup ); + $popup = $new_popup; + } + + return pum()->current_popup; } /** @@ -63,7 +70,10 @@ public static function current_popup( $new_popup = false ) { * @deprecated 1.21.0 Use \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups */ public static function get_loaded_popups() { - return \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups(); + self::$loaded = \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups_query(); + self::$loaded_ids = wp_list_pluck( self::$loaded->posts, 'ID' ); + + return self::$loaded; } /** diff --git a/classes/Telemetry.php b/classes/Telemetry.php index 5b71c46a6..322493bb0 100644 --- a/classes/Telemetry.php +++ b/classes/Telemetry.php @@ -304,6 +304,12 @@ public static function optin_alert( $alerts ) { public static function optin_alert_check( $code, $action ) { if ( 'pum_telemetry_notice' === $code ) { if ( 'pum_optin_check_allow' === $action ) { + // The alert dismiss handler only requires edit_posts; enabling + // telemetry is a settings-level decision. + if ( ! current_user_can( \PopupMaker\plugin()->get_permission( 'manage_settings' ) ) ) { + return; + } + pum_update_option( 'telemetry', true ); } } diff --git a/classes/Upsell.php b/classes/Upsell.php index 9c46fa5e7..ed0645d46 100644 --- a/classes/Upsell.php +++ b/classes/Upsell.php @@ -43,6 +43,12 @@ public static function init() { * @since 1.14.0 */ public static function notice_bar_display() { + // pum_is_admin_page() trusts the post_type param; gate on capability so the + // notice can't leak installed integrations to low-privileged users. + if ( ! current_user_can( plugin()->get_permission( 'edit_popups' ) ) ) { + return; + } + if ( pum_is_admin_page() ) { // Temporarily disable for CTA post type screens. if ( isset( $_GET['page'] ) && 'popup-maker-call-to-actions' === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended @@ -204,7 +210,9 @@ private static function get_notice_bar_triggers() { // New installs (after form tracking shipped) get celebration messaging. // Existing installs get "tracking is now live" messaging instead. - $installed_on = get_option( 'pum_installed_on', '' ); + // The legacy pum_installed_on option is removed after migration; read the + // install date from the current version info instead. + $installed_on = \PopupMaker\get_current_install_info( 'installed_on' ); $is_new_install = ! empty( $installed_on ) && strtotime( $installed_on ) >= strtotime( '2026-03-25' ); $triggers = [ diff --git a/classes/Utils/Blocks.php b/classes/Utils/Blocks.php index f2223b8d2..ee2d22243 100644 --- a/classes/Utils/Blocks.php +++ b/classes/Utils/Blocks.php @@ -35,7 +35,17 @@ public static function find_blocks( $blocks, $search_name = 'pum/*' ) { $found_blocks = array_merge( $found_blocks, self::find_blocks( $block['innerBlocks'], $search_name ) ); } - if ( $search_name === $block['blockName'] ) { + $block_name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; + + if ( '/*' === substr( $search_name, -2 ) ) { + // Wildcard like 'pum/*' matches any block sharing the prefix. + $prefix = substr( $search_name, 0, -1 ); + $matches = '' !== $block_name && 0 === strpos( $block_name, $prefix ); + } else { + $matches = $search_name === $block_name; + } + + if ( $matches ) { $found_blocks[] = $block; } } diff --git a/classes/Utils/Template.php b/classes/Utils/Template.php index bd5af68e3..2648a2be2 100644 --- a/classes/Utils/Template.php +++ b/classes/Utils/Template.php @@ -151,7 +151,7 @@ public static function render( $template, $args = [] ) { return; } - if ( $args ) { + if ( is_array( $args ) && ! empty( $args ) ) { // phpcs:ignore WordPress.PHP.DontExtract.extract_extract extract( $args ); } diff --git a/composer.json b/composer.json index d0114048a..1e012620c 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "lint:changed": "vendor/bin/phpcs --standard=.phpcs.xml.dist --report-full --report-checkstyle=./phpcs-report.xml --file-list=.changed-files.txt", "generate-stubs": "./bin/generate-stubs.sh", "install-strauss": [ - "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar" + "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar", + "echo 'bced6c576608ab67247c4c06d24e40fcdfd6ed3104347d3a4b8c451cacbe4c1f strauss.phar' | shasum -a 256 -c -" ], "clean-vendor-prefix-folder": [ "rm -rf vendor-prefixed/**/*" diff --git a/includes/integrations/class-pum-gravity-forms.php b/includes/integrations/class-pum-gravity-forms.php index 77e6b761a..2864c09be 100644 --- a/includes/integrations/class-pum-gravity-forms.php +++ b/includes/integrations/class-pum-gravity-forms.php @@ -16,6 +16,12 @@ public static function init() { add_action( 'popmake_preload_popup', [ __CLASS__, 'preload' ] ); add_action( 'popmake_popup_before_inner', [ __CLASS__, 'force_ajax' ] ); add_action( 'popmake_popup_after_inner', [ __CLASS__, 'force_ajax' ] ); + + // Popup content is pre-rendered & cached (blocks at priority 9, + // shortcodes at 11), so the template hooks above fire after the + // form has already rendered. Sandwich the content pipeline too. + add_filter( 'pum_popup_content', [ __CLASS__, 'begin_force_ajax' ], 5 ); + add_filter( 'pum_popup_content', [ __CLASS__, 'end_force_ajax' ], 99 ); } public static function force_ajax() { @@ -27,6 +33,32 @@ public static function force_ajax() { } } + /** + * Force AJAX on Gravity Forms rendered within popup content. + * + * @param string $content Popup content. + * + * @return string + */ + public static function begin_force_ajax( $content ) { + add_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + + /** + * Stop forcing AJAX once popup content has rendered. + * + * @param string $content Popup content. + * + * @return string + */ + public static function end_force_ajax( $content ) { + remove_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + public static function gfrorms_shortcode_atts( $out ) { $out['ajax'] = 'true'; @@ -63,7 +95,7 @@ public static function settings_menu( $setting_tabs ) { public static function get_form( $form_string, $form ) { $settings = wp_json_encode( self::form_options( $form['id'] ) ); - $field = ""; + $field = ''; $form_string = preg_replace( '/()/', "$1 \r\n " . $field, $form_string ); return $form_string; @@ -92,8 +124,10 @@ public static function defaults() { */ public static function form_options( $id ) { $settings = get_option( 'gforms_pum_' . $id, self::defaults() ); + $settings = wp_parse_args( $settings, self::defaults() ); - return wp_parse_args( $settings, self::defaults() ); + // Restrict to known keys so legacy/poisoned option data cannot reach the render sink. + return array_intersect_key( $settings, self::defaults() ); } /** @@ -273,12 +307,15 @@ public static function save() { // Check if JSON decode was successful. if ( is_array( $settings ) ) { - $settings['openpopup'] = ! empty( $settings['openpopup'] ); - $settings['openpopup_id'] = ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0; - $settings['closepopup'] = ! empty( $settings['closepopup'] ); - $settings['closedelay'] = ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0; - - update_option( 'gforms_pum_' . $form_id, $settings ); + // Only persist the known keys. Discard any attacker-supplied extras. + $clean = [ + 'openpopup' => ! empty( $settings['openpopup'] ), + 'openpopup_id' => ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0, + 'closepopup' => ! empty( $settings['closepopup'] ), + 'closedelay' => ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0, + ]; + + update_option( 'gforms_pum_' . $form_id, $clean ); } } else { delete_option( 'gforms_pum_' . $form_id ); diff --git a/includes/legacy/importer/easy-modal-v2.php b/includes/legacy/importer/easy-modal-v2.php index 931356e43..e4a0351b9 100644 --- a/includes/legacy/importer/easy-modal-v2.php +++ b/includes/legacy/importer/easy-modal-v2.php @@ -31,22 +31,22 @@ function popmake_emodal_v2_import() { global $wpdb, $popmake_options, $wp_version, $popmake_tools_page; - require_once POPMAKE_DIR . 'includes/importer/easy-modal-v2/functions.php'; + require_once POPMAKE_DIR . 'includes/legacy/importer/easy-modal-v2/functions.php'; if ( ! class_exists( 'EModal_Model' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model.php'; } if ( ! class_exists( 'EModal_Model_Modal' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/modal.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/modal.php'; } if ( ! class_exists( 'EModal_Model_Theme' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/theme.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/theme.php'; } if ( ! class_exists( 'EModal_Model_Theme_Meta' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/theme/meta.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/theme/meta.php'; } if ( ! class_exists( 'EModal_Model_Modal_Meta' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/modal/meta.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/modal/meta.php'; } $themes = get_all_modal_themes( '1 = 1' ); diff --git a/includes/namespaced/types.php b/includes/namespaced/types.php index 644fae55e..66e387808 100644 --- a/includes/namespaced/types.php +++ b/includes/namespaced/types.php @@ -46,7 +46,7 @@ function get_post_type_labels( $post_type ) { $post_type_object = get_post_type_object( $post_type ); - return $post_type_object ? $post_type_object->labels : []; + return $post_type_object ? (array) $post_type_object->labels : []; } /** diff --git a/includes/popup-templates/age-verification.php b/includes/popup-templates/age-verification.php new file mode 100644 index 000000000..f6f808477 --- /dev/null +++ b/includes/popup-templates/age-verification.php @@ -0,0 +1,74 @@ + 'age-verification', + 'name' => __( 'Age Verification', 'popup-maker' ), + 'description' => __( 'Modal gate requiring visitors to confirm their age before accessing age-restricted content.', 'popup-maker' ), + 'category' => 'compliance', + 'tier' => 'free', + 'keywords' => [ 'age', 'gate', 'verification', 'compliance', 'modal', 'adult' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Verify Your Age', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'This content is for adults only. Please verify your age to continue.', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'You must be at least 18 years old.', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'πŸ‘‰ Replace this paragraph with a date-of-birth field (WPForms, Gravity Forms, etc.) or native HTML date input.', 'popup-maker' ) + ), + ' + +', + ' + +', + sprintf( + '

%s

', + esc_html__( 'By continuing, you confirm you meet the age requirement and agree to our terms.', 'popup-maker' ) + ), + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 0, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '365 days', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Position as a blocking full-screen overlay on initial page load. No close affordance (X button) β€” use only "Leave Site" to enforce the gate. Set cookie on age confirmation to prevent re-verification for 1 year per compliance policy.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/announcement-banner.php b/includes/popup-templates/announcement-banner.php new file mode 100644 index 000000000..3b9851649 --- /dev/null +++ b/includes/popup-templates/announcement-banner.php @@ -0,0 +1,43 @@ + 'announcement-banner', + 'name' => __( 'Announcement Banner', 'popup-maker' ), + 'description' => __( 'Sticky top bar with centered message and optional right-aligned CTA button.', 'popup-maker' ), + 'category' => 'announcements', + 'tier' => 'free', + 'keywords' => [ 'announcement', 'banner', 'sticky', 'bar', 'top', 'alert', 'news' ], + 'viewport_width' => 480, + 'content' => '

' . esc_html__( 'Check out our latest feature release and see what\'s new.', 'popup-maker' ) . '

', + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 0, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 week', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Sticky top bar best positioned with Popup Maker popup position set to top-sticky. Inherits theme colors; customize background/text via block style editor. Dismiss via the close affordance (X).', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/contact-us.php b/includes/popup-templates/contact-us.php new file mode 100644 index 000000000..82361ee3a --- /dev/null +++ b/includes/popup-templates/contact-us.php @@ -0,0 +1,78 @@ + 'contact-us', + 'name' => __( 'Contact Us', 'popup-maker' ), + 'description' => __( 'Simple contact form popup with name, email, subject, and message fields.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'free', + 'keywords' => [ 'contact', 'form', 'inquiry', 'message', 'lead' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Get in Touch', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'We respond within 24 hours.', 'popup-maker' ) + ), + '', + sprintf( + '

%s

', + esc_html__( 'πŸ‘‰ Replace this paragraph with your form block (WPForms, Gravity Forms, Fluent Forms, etc.).', 'popup-maker' ) + ), + '', + ' + +', + sprintf( + ' +
+ +
+', + esc_html__( 'Cancel', 'popup-maker' ) + ), + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'click_open', + 'settings' => [ + 'extra_selectors' => '', + 'cookie_name' => null, + ], + ], + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 day', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Integrate your form block (WPForms, Gravity Forms, Fluent Forms, etc.) by replacing the placeholder paragraph. Connect the CTA button to a form submission handler or custom integration to capture lead data.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/cookie-notice.php b/includes/popup-templates/cookie-notice.php new file mode 100644 index 000000000..ff5b0df5a --- /dev/null +++ b/includes/popup-templates/cookie-notice.php @@ -0,0 +1,78 @@ + 'cookie-notice', + 'name' => __( 'Cookie Notice', 'popup-maker' ), + 'description' => __( 'Lightweight GDPR-compliant cookie consent banner with accept and reject options.', 'popup-maker' ), + 'category' => 'compliance', + 'tier' => 'free', + 'keywords' => [ 'gdpr', 'cookies', 'consent', 'privacy', 'compliance', 'banner' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + '
', + sprintf( + '

%s

', + esc_html__( 'We use cookies to improve your experience on our site. Learn more in our privacy policy.', 'popup-maker' ) + ), + '', + '', + '
', + '', + sprintf( + '', + esc_html__( 'Reject All', 'popup-maker' ) + ), + '', + '', + '
', + sprintf( + '', + esc_html__( 'Accept All', 'popup-maker' ) + ), + '
', + '', + '
', + '', + '
', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 0, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 year', + 'session' => false, + 'path' => true, + ], + ], + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 year', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Connect both buttons to "Simple Close" call-to-action type. Position as sticky bottom bar for unobtrusive GDPR compliance. Fires immediately on page load; respects consent cookie for 1 year.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/discount-coupon.php b/includes/popup-templates/discount-coupon.php new file mode 100644 index 000000000..1d2b92738 --- /dev/null +++ b/includes/popup-templates/discount-coupon.php @@ -0,0 +1,71 @@ + 'discount-coupon', + 'name' => __( 'Discount Coupon', 'popup-maker' ), + 'description' => __( 'Announce a promotional offer with a prominent, copyable coupon code.', 'popup-maker' ), + 'category' => 'sales-promotions', + 'tier' => 'free', + 'keywords' => [ 'coupon', 'discount', 'promo', 'sale', 'code' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Limited-time offer', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Get 20% off your order with this exclusive code.', 'popup-maker' ) + ), + sprintf( + '

SAVE20

%s

', + esc_html__( 'Copy and paste at checkout', 'popup-maker' ) + ), + sprintf( + ' +
+ +
+', + esc_html__( 'Shop the Sale', 'popup-maker' ) + ), + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + [ + 'type' => 'click_open', + 'settings' => [ + 'extra_selectors' => '', + 'cookie_name' => null, + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 week', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Sized for standard desktop/tablet viewing. Connect the button to an Apply Discount call to action to track conversions and include a custom redirect URL.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/flash-sale-promo.php b/includes/popup-templates/flash-sale-promo.php new file mode 100644 index 000000000..ab2f921fc --- /dev/null +++ b/includes/popup-templates/flash-sale-promo.php @@ -0,0 +1,64 @@ + 'flash-sale-promo', + 'name' => __( 'Flash Sale Promo', 'popup-maker' ), + 'description' => __( 'Time-sensitive discount offer with benefit highlights and prominent call-to-action.', 'popup-maker' ), + 'category' => 'sales-promotions', + 'tier' => 'free', + 'keywords' => [ 'sales', 'promotion', 'discount', 'offer', 'flash', 'limited-time' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Limited Time: 20% Off Today', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Unlock your exclusive offer now.', 'popup-maker' ) + ), + '
  • ' . esc_html__( 'Free shipping on orders over $50', 'popup-maker' ) . '
  • ' . esc_html__( 'Extended 90-day returns', 'popup-maker' ) . '
  • ' . esc_html__( 'VIP member benefits', 'popup-maker' ) . '
', + '', + ' + +', + ' + +', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 week', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Position on product or shop pages to maximize conversion. 500–600px centered modal works best on desktop; mobile automatically adapts to bottom sheet. Set 7-day cookie to avoid over-exposure.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/lead-magnet-download.php b/includes/popup-templates/lead-magnet-download.php new file mode 100644 index 000000000..3f84e17d0 --- /dev/null +++ b/includes/popup-templates/lead-magnet-download.php @@ -0,0 +1,80 @@ + 'lead-magnet-download', + 'name' => __( 'Lead Magnet - Content Upgrade', 'popup-maker' ), + 'description' => __( 'Split-column capture popup with benefit bullets and email-to-download CTA.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'free', + 'keywords' => [ 'lead', 'magnet', 'download', 'email', 'capture', 'content', 'upgrade' ], + 'viewport_width' => 600, + 'content' => implode( "\n\n", [ + // Columns container for split layout. + '
', + // Left column: visual anchor with highlight. + '
' . + sprintf( + '

%s

%s

', + esc_html__( 'Your Guide Awaits', 'popup-maker' ), + esc_html__( 'Free instant downloadβ€”no spam guarantee', 'popup-maker' ) + ) . + '
', + // Right column: form and CTA. + '
' . + sprintf( + '

%s

%s

  • %s
  • %s
  • %s

%s

', + esc_html__( 'Get Our Free Marketing Playbook', 'popup-maker' ), + esc_html__( 'Used by 5000+ agencies worldwide.', 'popup-maker' ), + esc_html__( 'Step-by-step checklists', 'popup-maker' ), + esc_html__( 'Real campaign templates', 'popup-maker' ), + esc_html__( 'Conversion optimization tips', 'popup-maker' ), + esc_html__( 'Enter your email to download instantly', 'popup-maker' ) + ) . + sprintf( + '

%s

', + esc_html__( 'πŸ‘‰ Replace this paragraph with your form block (WPForms, Gravity Forms, Fluent Forms, etc.).', 'popup-maker' ) + ) . + '' . + '
', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + [ + 'type' => 'click_open', + 'settings' => [ + 'extra_selectors' => '', + 'cookie_name' => null, + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '30 days', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Set modal width to 600px for optimal split layout. Connect CTA to a redirect-type call-to-action that links to your download or thank-you page. Mobile renders as single column; consider bottom-sheet positioning on smartphones.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/newsletter-signup.php b/includes/popup-templates/newsletter-signup.php new file mode 100644 index 000000000..174f31aaf --- /dev/null +++ b/includes/popup-templates/newsletter-signup.php @@ -0,0 +1,68 @@ + 'newsletter-signup', + 'name' => __( 'Newsletter Signup', 'popup-maker' ), + 'description' => __( 'Minimalist two-field email signup with headline, subheading, and subscribe button.', 'popup-maker' ), + 'category' => 'subscribe', + 'tier' => 'free', + 'keywords' => [ 'newsletter', 'signup', 'email', 'subscribe', 'lead', 'list', 'capture' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Stay in the Loop', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Get exclusive tips and updates delivered to your inbox weekly. No spam, ever.', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'πŸ‘‰ Replace this paragraph with your form block (WPForms, Gravity Forms, Fluent Forms, etc.).', 'popup-maker' ) + ), + ' + +', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 6000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + [ + 'type' => 'click_open', + 'settings' => [ + 'extra_selectors' => '', + 'cookie_name' => null, + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_conversion', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 month', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Best on high-intent pages (blog, resources, homepage). Set auto-open to 6–10 seconds. Use form plugin integration for email capture; attach CTA to "Form Submission" or custom email provider action.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/social-follow.php b/includes/popup-templates/social-follow.php new file mode 100644 index 000000000..2640a9118 --- /dev/null +++ b/includes/popup-templates/social-follow.php @@ -0,0 +1,59 @@ + 'social-follow', + 'name' => __( 'Social Follow', 'popup-maker' ), + 'description' => __( 'Compact modal with headline and social media links for followers.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'free', + 'keywords' => [ 'social', 'follow', 'engagement', 'compact', 'links' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Let\'s stay connected', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Follow us for the latest updates and exclusive content.', 'popup-maker' ) + ), + '', + '', + '', + sprintf( + '

%s

', + esc_html__( 'Dismiss', 'popup-maker' ) + ), + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 5000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 month', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Edit social links in the editor to point to your accounts. Auto-open after 5 seconds; adjust timing and repeat frequency via triggers tab.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/testimonial-proof.php b/includes/popup-templates/testimonial-proof.php new file mode 100644 index 000000000..c40dcb9a7 --- /dev/null +++ b/includes/popup-templates/testimonial-proof.php @@ -0,0 +1,65 @@ + 'testimonial-proof', + 'name' => __( 'Testimonial Proof', 'popup-maker' ), + 'description' => __( 'Compact notification showing recent customer activity to build social proof and FOMO.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'free', + 'keywords' => [ 'social', 'proof', 'notification', 'testimonial', 'fomo', 'engagement' ], + 'viewport_width' => 340, + 'content' => implode( "\n\n", [ + sprintf( + '
%s
', + implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'Just purchased', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Sarah M. in New York', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Premium Plan', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( '2 minutes ago', 'popup-maker' ) + ), + ] ) + ), + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 5000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 week', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Compact corner notification (340px width) for rotating customer testimonials. Use trigger delay 5–10 seconds for page entry. Omit cookie to show every visit for max FOMO effect.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/video-showcase.php b/includes/popup-templates/video-showcase.php new file mode 100644 index 000000000..33c992847 --- /dev/null +++ b/includes/popup-templates/video-showcase.php @@ -0,0 +1,72 @@ + 'video-showcase', + 'name' => __( 'Video Showcase', 'popup-maker' ), + 'description' => __( 'Featured video with headline and CTA button.', 'popup-maker' ), + 'category' => 'engagement', + 'tier' => 'free', + 'keywords' => [ 'video', 'demo', 'embed', 'engagement', 'showcase' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + sprintf( + '

%s

', + esc_html__( 'See It in Action', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Watch our 2-minute product demo.', 'popup-maker' ) + ), + '', + '', + '', + ' + +', + ' + +', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'click_open', + 'settings' => [ + 'extra_selectors' => '', + 'cookie_name' => null, + ], + ], + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '1 hour', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Ideal for product demos on landing pages or testimonial sections. Replace the embed block with your YouTube/Vimeo URL. Set a 1-hour cookie to prevent replay fatigue.', 'popup-maker' ), + ], +]; diff --git a/includes/popup-templates/welcome-mat.php b/includes/popup-templates/welcome-mat.php new file mode 100644 index 000000000..937181267 --- /dev/null +++ b/includes/popup-templates/welcome-mat.php @@ -0,0 +1,65 @@ + 'welcome-mat', + 'name' => __( 'Welcome Mat', 'popup-maker' ), + 'description' => __( 'Full-screen hero overlay welcoming new visitors with a headline, subheading, and prominent call-to-action.', 'popup-maker' ), + 'category' => 'lead-capture', + 'tier' => 'free', + 'keywords' => [ 'welcome', 'hero', 'overlay', 'fullscreen', 'new-visitor', 'conversion' ], + 'viewport_width' => 480, + 'content' => implode( "\n\n", [ + '
', + sprintf( + '

%s

', + esc_html__( 'Level Up Your Marketing', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Join 10,000+ marketers getting better results.', 'popup-maker' ) + ), + sprintf( + '

%s

', + esc_html__( 'Grab exclusive strategies and case studies sent straight to your inbox.', 'popup-maker' ) + ), + '', + '', + '', + sprintf( + '

%s

', + esc_html__( 'Skip for now', 'popup-maker' ) + ), + '
', + ] ), + 'recommended' => [ + 'triggers' => [ + [ + 'type' => 'auto_open', + 'settings' => [ + 'delay' => 3000, + 'cookie_name' => [ 'pum-{popup_id}' ], + ], + ], + ], + 'cookies' => [ + [ + 'event' => 'on_popup_close', + 'settings' => [ + 'name' => 'pum-{popup_id}', + 'time' => '60 days', + 'session' => false, + 'path' => true, + ], + ], + ], + 'notes' => __( 'Full-screen welcome mat sized for mobile and desktop. Best applied on homepage or landing pages; set to 3–5 second auto-open delay to let content load before triggering. Dismiss text link styled for white overlay. Connect the button to a newsletter signup or lead capture call-to-action.', 'popup-maker' ), + ], +]; diff --git a/package.json b/package.json index 7b43e52cb..7dba04118 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "webpack-bundle-analyzer": "^4.10.2" }, "scripts": { - "preinstall": "npx -y only-allow pnpm", + "preinstall": "npx -y only-allow@1.2.2 pnpm", "build": "wp-scripts build --config webpack.config.js --config webpack.old.config.js", "build:production": "NODE_ENV=production wp-scripts build --mode production --config webpack.config.js --config webpack.old.config.js", "build:tsc": "pnpm -r --sort --if-present run build:tsc", diff --git a/packages/admin-bar/src/AdminBar.ts b/packages/admin-bar/src/AdminBar.ts index d2a0453e6..43040543c 100644 --- a/packages/admin-bar/src/AdminBar.ts +++ b/packages/admin-bar/src/AdminBar.ts @@ -9,6 +9,21 @@ declare const popupMakerAdminBar: } | undefined; +/** + * Escape a string for safe interpolation into innerHTML. + * + * @param {string} value Untrusted string. + * @return {string} HTML-escaped string. + */ +function escapeHtml( value: string ): string { + return String( value ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); +} + interface ModalOptions { title: string; content: string; @@ -225,7 +240,7 @@ export class AdminBar { title: this.text.results, content: `
-

${ selector }

+

${ escapeHtml( selector ) }

+ + { isOpen && ( + setIsOpen( false ) } + /> + ) } + + ); +}; + +registerPlugin( 'popup-maker-template-library', { + render: TemplateLibraryPlugin, +} ); + +export default TemplateLibraryPlugin; diff --git a/packages/block-editor/src/plugins/template-library/modal.tsx b/packages/block-editor/src/plugins/template-library/modal.tsx new file mode 100644 index 000000000..7f3a05204 --- /dev/null +++ b/packages/block-editor/src/plugins/template-library/modal.tsx @@ -0,0 +1,378 @@ +/** + * Popup template picker modal. + */ + +import { __, sprintf } from '@wordpress/i18n'; +import { Button, Modal, SearchControl, Notice } from '@wordpress/components'; +import { useMemo, useState } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { parse } from '@wordpress/blocks'; +import { lock } from '@wordpress/icons'; +import { + // @ts-expect-error + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalBlockPreview as BlockPreview, +} from '@wordpress/block-editor'; + +import { applyRecommendedSettings, canApplySettings } from './apply-settings'; +import type { PopupTemplate, TemplateLibraryData } from './types'; + +const TRIGGER_LABELS: Record< string, string > = { + auto_open: __( 'Time delay', 'popup-maker' ), + click_open: __( 'Click', 'popup-maker' ), + form_submission: __( 'Form submission', 'popup-maker' ), + exit_intent: __( 'Exit intent', 'popup-maker' ), + scroll: __( 'Scroll', 'popup-maker' ), + time_on_site: __( 'Time on site', 'popup-maker' ), +}; + +const COOKIE_LABELS: Record< string, string > = { + on_popup_close: __( 'Set cookie when popup is closed', 'popup-maker' ), + on_popup_open: __( 'Set cookie when popup opens', 'popup-maker' ), + on_popup_conversion: __( 'Set cookie on conversion', 'popup-maker' ), + form_submission: __( 'Set cookie on form submission', 'popup-maker' ), + manual: __( 'Manual cookie', 'popup-maker' ), +}; + +interface TemplateCardProps { + template: PopupTemplate; + tierLabel: string; + onSelect: ( template: PopupTemplate ) => void; +} + +const TemplateCard = ( { + template, + tierLabel, + onSelect, +}: TemplateCardProps ) => { + const blocks = useMemo( + () => ( template.content ? parse( template.content ) : [] ), + [ template.content ] + ); + + return ( +
+ ) : ( + + ) } + +
+ + { template.name } + + { 'free' !== template.tier && ( + + { tierLabel } + + ) } +
+ + { template.description } + + + ); +}; + +interface ApplySettingsStepProps { + template: PopupTemplate; + onFinish: ( apply: boolean ) => void; +} + +const ApplySettingsStep = ( { + template, + onFinish, +}: ApplySettingsStepProps ) => { + const { triggers, cookies, notes } = template.recommended; + + return ( +
+

+ { __( + 'This template ships with recommended popup settings. Apply them now? You can adjust everything afterward in the Popup Settings box.', + 'popup-maker' + ) } +

+
    + { triggers.map( ( trigger, i ) => ( +
  • + { sprintf( + /* translators: %s: trigger type label. */ + __( 'Trigger: %s', 'popup-maker' ), + TRIGGER_LABELS[ trigger.type ] ?? trigger.type + ) } +
  • + ) ) } + { cookies.map( ( cookie, i ) => ( +
  • + { COOKIE_LABELS[ cookie.event ] ?? cookie.event } +
  • + ) ) } +
+ { notes && ( + + { notes } + + ) } +
+ + +
+
+ ); +}; + +interface TemplateLibraryModalProps { + data: TemplateLibraryData; + onClose: () => void; +} + +const TemplateLibraryModal = ( { + data, + onClose, +}: TemplateLibraryModalProps ) => { + const [ category, setCategory ] = useState< string >( 'all' ); + const [ search, setSearch ] = useState( '' ); + const [ applyStep, setApplyStep ] = useState< PopupTemplate | null >( + null + ); + + const { insertBlocks, resetBlocks } = useDispatch( 'core/block-editor' ); + const { createSuccessNotice } = useDispatch( 'core/notices' ); + + const { blocks: currentBlocks, popupId } = useSelect( ( select ) => { + const blockEditor = select( 'core/block-editor' ) as unknown as { + getBlocks: () => { + name: string; + attributes?: Record< string, unknown >; + }[]; + }; + const editor = select( 'core/editor' ) as unknown as { + getCurrentPostId: () => number; + }; + + return { + blocks: blockEditor.getBlocks(), + popupId: editor.getCurrentPostId(), + }; + }, [] ); + + const tierLabel = ( template: PopupTemplate ): string => + 'pro_plus' === template.tier + ? data.i10n.proPlusLabel + : data.i10n.proLabel; + + const filtered = useMemo( () => { + const term = search.trim().toLowerCase(); + + return data.templates.filter( ( template ) => { + if ( 'all' !== category && template.category !== category ) { + return false; + } + + if ( ! term ) { + return true; + } + + const haystack = [ + template.name, + template.description, + ...( template.keywords || [] ), + ] + .join( ' ' ) + .toLowerCase(); + + return haystack.includes( term ); + } ); + }, [ data.templates, category, search ] ); + + const insertTemplate = ( template: PopupTemplate ) => { + const blocks = parse( template.content ); + + const editorIsEmpty = + currentBlocks.length === 0 || + ( currentBlocks.length === 1 && + 'core/paragraph' === currentBlocks[ 0 ].name && + ! ( + currentBlocks[ 0 ].attributes?.content as + | { length?: number } + | undefined + )?.length ); + + if ( editorIsEmpty ) { + resetBlocks( blocks ); + } else { + insertBlocks( blocks ); + } + + createSuccessNotice( + sprintf( + /* translators: %s: template name. */ + __( 'β€œ%s” template inserted.', 'popup-maker' ), + template.name + ), + { type: 'snackbar' } + ); + + const hasRecommendations = + template.recommended.triggers.length > 0 || + template.recommended.cookies.length > 0; + + if ( hasRecommendations && canApplySettings() ) { + setApplyStep( template ); + return; + } + + onClose(); + }; + + const handleSelect = ( template: PopupTemplate ) => { + if ( template.proRequired ) { + window.open( template.upgradeUrl, '_blank', 'noopener' ); + return; + } + + insertTemplate( template ); + }; + + const finishApplyStep = ( apply: boolean ) => { + if ( apply && applyStep ) { + const applied = applyRecommendedSettings( + applyStep.recommended, + popupId + ); + + if ( applied.triggers || applied.cookies ) { + createSuccessNotice( + __( + 'Recommended settings added. Review them in the Popup Settings box, then save.', + 'popup-maker' + ), + { type: 'snackbar' } + ); + } + } + + setApplyStep( null ); + onClose(); + }; + + return ( + + { applyStep ? ( + + ) : ( +
+
+ +
    +
  • + +
  • + { Object.entries( data.categories ).map( + ( [ slug, label ] ) => ( +
  • + +
  • + ) + ) } +
+
+
+ { filtered.map( ( template ) => ( + + ) ) } + { ! filtered.length && ( +

+ { __( + 'No templates match your search.', + 'popup-maker' + ) } +

+ ) } +
+
+ ) } +
+ ); +}; + +export default TemplateLibraryModal; diff --git a/packages/block-editor/src/plugins/template-library/types.ts b/packages/block-editor/src/plugins/template-library/types.ts new file mode 100644 index 000000000..1662f629e --- /dev/null +++ b/packages/block-editor/src/plugins/template-library/types.ts @@ -0,0 +1,59 @@ +/** + * Popup template library types. + */ + +export interface RecommendedTrigger { + type: string; + settings: Record< string, unknown >; +} + +export interface RecommendedCookie { + event: string; + settings: Record< string, unknown >; +} + +export interface TemplateRecommended { + triggers: RecommendedTrigger[]; + cookies: RecommendedCookie[]; + notes: string; +} + +export interface PopupTemplate { + slug: string; + name: string; + description: string; + category: string; + tier: 'free' | 'pro' | 'pro_plus'; + keywords: string[]; + viewportWidth: number; + content: string; + recommended: TemplateRecommended; + proRequired: boolean; + upgradeUrl: string; +} + +export interface TemplateLibraryData { + templates: PopupTemplate[]; + categories: Record< string, string >; + i10n: { + proLabel: string; + proPlusLabel: string; + }; +} + +/** + * Get the localized template library data, if present. + * + * Only localized on the popup editor screen. + */ +export function getTemplateLibraryData(): TemplateLibraryData | undefined { + const vars = ( + window as unknown as { + popupMakerBlockEditor?: { + templateLibrary?: TemplateLibraryData; + }; + } + ).popupMakerBlockEditor; + + return vars?.templateLibrary; +} diff --git a/packages/block-library/src/lib/cta-button/deprecated.ts b/packages/block-library/src/lib/cta-button/deprecated.ts index 84cfa9d04..c675947fa 100644 --- a/packages/block-library/src/lib/cta-button/deprecated.ts +++ b/packages/block-library/src/lib/cta-button/deprecated.ts @@ -1,3 +1,146 @@ -const deprecated = []; +/** + * External dependencies + */ +import clsx from 'clsx'; +import React from 'react'; + +/** + * WordPress dependencies + */ +import { + RichText, + useBlockProps, + getTypographyClassesAndStyles, + // @ts-expect-error + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, + // @ts-expect-error + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalGetColorClassesAndStyles as getColorClassesAndStyles, + // @ts-expect-error + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles, + // @ts-expect-error + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalGetShadowClassesAndStyles as getShadowClassesAndStyles, +} from '@wordpress/block-editor'; + +interface DeprecatedButtonAttributes { + tagName?: string; + type?: string; + textAlign?: string; + fontSize?: string; + linkTarget?: string; + rel?: string; + style?: { + border?: { + radius?: number; + }; + typography?: { + fontSize?: string; + }; + }; + text?: string; + title?: string; + url?: string; + width?: number; +} + +interface DeprecatedSaveProps { + attributes: DeprecatedButtonAttributes; + className?: string; +} + +const saveWithClasses = + ( baseClasses: string[] ) => + ( { attributes, className }: DeprecatedSaveProps ) => { + const { + tagName, + type, + textAlign, + fontSize, + linkTarget, + rel, + style, + text, + title, + url, + width, + } = attributes; + + const TagName = tagName || 'a'; + const isButtonTag = 'button' === TagName; + const buttonType = type || 'button'; + const borderProps = getBorderClassesAndStyles( attributes ); + const colorProps = getColorClassesAndStyles( attributes ); + const spacingProps = getSpacingClassesAndStyles( attributes ); + const shadowProps = getShadowClassesAndStyles( attributes ); + // @ts-expect-error + const typographyProps = getTypographyClassesAndStyles( attributes ); + + const buttonClasses = clsx( + baseClasses, + colorProps.className, + borderProps.className, + typographyProps.className, + { + [ `has-text-align-${ textAlign }` ]: textAlign, + 'no-border-radius': style?.border?.radius === 0, + [ `has-custom-font-size` ]: + fontSize || style?.typography?.fontSize, + } + ); + const buttonStyle = { + ...borderProps.style, + ...colorProps.style, + ...spacingProps.style, + ...shadowProps.style, + ...typographyProps.style, + writingMode: undefined, + }; + + const wrapperClasses = clsx( className, { + [ `has-custom-width wp-block-popup-maker-cta-button__width-${ width }` ]: + Boolean( width ), + } ); + + return React.createElement( + 'div', + useBlockProps.save( { className: wrapperClasses } ), + React.createElement( RichText.Content, { + tagName: TagName, + type: isButtonTag ? buttonType : null, + className: buttonClasses, + href: isButtonTag ? null : url, + title, + style: buttonStyle, + value: text, + target: isButtonTag ? null : linkTarget, + rel: isButtonTag ? null : rel, + } ) + ); + }; + +const deprecated = [ + { + apiVersion: 3, + attributes: {}, + save: saveWithClasses( [ + 'wp-block-popup-maker-cta-button__link', + 'wp-element-button', + ] ), + migrate( attributes: DeprecatedButtonAttributes ) { + return attributes; + }, + }, + { + apiVersion: 3, + attributes: {}, + save: saveWithClasses( [ 'wp-block-popup-maker-cta-button__link' ] ), + migrate( attributes: DeprecatedButtonAttributes ) { + return attributes; + }, + }, +]; export default deprecated; diff --git a/packages/block-library/src/lib/cta-button/edit.native.tsx b/packages/block-library/src/lib/cta-button/edit.native.tsx index bd2fdf31c..1eced45b2 100644 --- a/packages/block-library/src/lib/cta-button/edit.native.tsx +++ b/packages/block-library/src/lib/cta-button/edit.native.tsx @@ -10,6 +10,9 @@ import { useCallback, useEffect, useState, useRef } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; import { __, _x } from '@popup-maker/i18n'; import { + RichText, + BlockControls, + InspectorControls, store as blockEditorStore, getColorObjectByAttributeValues, getGradientValueBySlug, @@ -22,6 +25,10 @@ import { getValueAndUnit, BottomSheetSelectControl, CSS_UNITS, + PanelBody, + ToolbarGroup, + ToolbarButton, + filterUnitsWithSettings, } from '@wordpress/components'; import { link } from '@wordpress/icons'; // eslint-disable-next-line no-restricted-imports @@ -32,6 +39,7 @@ import { store as editPostStore } from '@wordpress/edit-post'; */ import richTextStyle from './rich-text.scss'; import styles from './editor.scss'; +import ColorBackground from './color-background.native'; const MIN_BORDER_RADIUS_VALUE = 0; const MAX_BORDER_RADIUS_VALUE = 50; @@ -81,7 +89,7 @@ function WidthPanel( { selectedWidth, setAttributes } ) { } function ButtonEdit( props ) { - const { isSelected, parentWidth } = props; + const { isSelected, parentWidth, clientId } = props; const initialBorderRadius = props?.attributes?.style?.border?.radius; const { valueUnit = 'px' } = getValueAndUnit( initialBorderRadius ) || {}; @@ -399,14 +407,7 @@ function ButtonEdit( props ) { return defaultBorderRadius; } - const { - attributes, - clientId, - onReplace, - mergeBlocks, - setAttributes, - style, - } = props; + const { attributes, onReplace, mergeBlocks, setAttributes, style } = props; const { placeholder, text, diff --git a/packages/block-library/src/lib/cta-button/edit.tsx b/packages/block-library/src/lib/cta-button/edit.tsx index e16e48979..558dfff3a 100644 --- a/packages/block-library/src/lib/cta-button/edit.tsx +++ b/packages/block-library/src/lib/cta-button/edit.tsx @@ -100,7 +100,7 @@ interface ButtonAttributes { text: string; url?: string; width?: number; - metadata?: any; + metadata?: unknown; ctaId?: number; } @@ -109,10 +109,10 @@ interface ButtonEditProps { setAttributes: ( attrs: Partial< ButtonAttributes > ) => void; className?: string; isSelected: boolean; - onReplace: ( blocks: any[] ) => void; + onReplace: ( blocks: BlockInstance[] ) => void; mergeBlocks: ( forward: boolean ) => void; clientId: string; - context: any; + context: unknown; } interface WidthPanelProps { @@ -243,7 +243,6 @@ function ButtonEdit( props: ButtonEditProps ) { onReplace, mergeBlocks, clientId, - context, } = props; const { @@ -254,9 +253,7 @@ function ButtonEdit( props: ButtonEditProps ) { rel, style, text, - url, width, - metadata, ctaId, } = attributes; @@ -270,7 +267,7 @@ function ButtonEdit( props: ButtonEditProps ) { /** * Flag: Whether to show the editor for creating a new CTA. */ - const [ newCta, setNewCta ] = useState< number | boolean >( false ); + const [ newCta, setNewCta ] = useState< number | 'new' | false >( false ); /** * Flag: Whether the user is editing a CTA. @@ -327,11 +324,19 @@ function ButtonEdit( props: ButtonEditProps ) { const isLinkTag = 'a' === TagName; // Get available CTAs from the store - const selectedCTA = useSelect( - ( select ) => - ctaId - ? select( callToActionStore ).getCallToAction( ctaId ) - : undefined, + const { selectedCTA, hasResolvedSelectedCTA } = useSelect( + ( select ) => { + const state = select( callToActionStore ); + + return { + selectedCTA: ctaId ? state.getCallToAction( ctaId ) : undefined, + hasResolvedSelectedCTA: ctaId + ? state.hasFinishedResolution( 'getCallToAction', [ + ctaId, + ] ) + : false, + }; + }, [ ctaId ] ); @@ -344,8 +349,21 @@ function ButtonEdit( props: ButtonEditProps ) { [] ); + const blockLibraryVars = window.popupMakerBlockLibrary as { + homeUrl?: string; + paramNames?: { + cta?: string; + popup_id?: string; + notrack?: string; + }; + }; + // Get localized home URL - const homeUrl = window.popupMakerBlockLibrary?.homeUrl || '/'; + const homeUrl = blockLibraryVars?.homeUrl || '/'; + const paramNames = blockLibraryVars?.paramNames || {}; + const ctaParamName = paramNames.cta || 'cta'; + const popupIdParamName = paramNames.popup_id || 'pid'; + const notrackParamName = paramNames.notrack || 'notrack'; /** * Helper function to generate CTA URLs with proper base URL and conditional parameters. @@ -358,28 +376,40 @@ function ButtonEdit( props: ButtonEditProps ) { const generateCtaUrl = useCallback( ( ctaUuid: string, notrack = false ): string => { const params = new URLSearchParams(); - params.set( 'cta', ctaUuid ); + params.set( ctaParamName, ctaUuid ); // Add post ID if editing a popup if ( currentPostType === 'popup' && currentPostId ) { - params.set( 'pid', currentPostId.toString() ); + params.set( popupIdParamName, currentPostId.toString() ); } // Add notrack parameter if requested if ( notrack ) { - params.set( 'notrack', '1' ); + params.set( notrackParamName, '1' ); } // Remove trailing slash from home URL and add query parameters const baseUrl = homeUrl.replace( /\/$/, '' ); return `${ baseUrl }/?${ params.toString() }`; }, - [ currentPostId, currentPostType, homeUrl ] + [ + ctaParamName, + currentPostId, + currentPostType, + homeUrl, + notrackParamName, + popupIdParamName, + ] ); - const { createCallToAction, changeEditorId } = + const { changeEditorId, updateEditorValues } = useDispatch( callToActionStore ); + const callToActionSelectors = useSelect( + ( select ) => select( callToActionStore ), + [] + ); + function startEditing() { setIsExplicitlyEditing( true ); } @@ -416,7 +446,7 @@ function ButtonEdit( props: ButtonEditProps ) { ? NOFOLLOW_REL : undefined, } ); - } else { + } else if ( hasResolvedSelectedCTA ) { setAttributes( { url: undefined, linkTarget: undefined, @@ -427,6 +457,7 @@ function ButtonEdit( props: ButtonEditProps ) { }, [ ctaId, selectedCTA, + hasResolvedSelectedCTA, currentPostType, currentPostId, homeUrl, @@ -454,40 +485,15 @@ function ButtonEdit( props: ButtonEditProps ) { ( !! ctaId || isExplicitlyEditing ) && !! popoverAnchor; - const [ _forceRefresh, setForceRefresh ] = useState( 0 ); - - // If a new CTA is created, don't show the popover - useEffect( () => { - async function createNewCTA() { - if ( false === newCta ) { - return; - } - - if ( true === newCta ) { - const createdCta = await createCallToAction( { - title: __( 'New call to action', 'popup-maker' ), - status: 'publish', - } ); - - if ( createdCta ) { - changeEditorId( createdCta.id ); - setNewCta( createdCta.id ); - // Set editing state after the CTA is created, not before - setIsExplicitlyEditing( true ); - } else { - // Reset if creation failed - setNewCta( false ); - } - } else if ( typeof newCta === 'number' && newCta > 0 ) { - // CTA was already created, just ensure editing state - setIsExplicitlyEditing( true ); - } - - setForceRefresh( ( prev ) => prev + 1 ); - } - - createNewCTA(); - }, [ newCta, createCallToAction, changeEditorId ] ); + async function openNewCTA() { + await changeEditorId( 'new' ); + await updateEditorValues( { + title: __( 'New call to action', 'popup-maker' ), + status: 'publish', + } ); + setNewCta( 'new' ); + setIsExplicitlyEditing( true ); + } const [ fluidTypographySettings, layout ] = useSettings( 'typography.fluid', @@ -608,15 +614,15 @@ function ButtonEdit( props: ButtonEditProps ) { isActive /> ) } - - { showPopover && ( - { - setIsExplicitlyEditing( false ); - ( richTextRef.current as any )?.focus?.(); - } } - anchor={ popoverAnchor } + + { showPopover && ( + { + setIsExplicitlyEditing( false ); + richTextRef.current?.focus?.(); + } } + anchor={ popoverAnchor } focusOnMount={ isEditingCTA ? 'firstElement' : false } __unstableSlotName="__unstable-block-tools-after" shift @@ -644,7 +650,7 @@ function ButtonEdit( props: ButtonEditProps ) { newId: number | string ) => { if ( newId === 'create_new' ) { - setNewCta( true ); + await openNewCTA(); return; } setAttributes( { @@ -903,7 +909,7 @@ function ButtonEdit( props: ButtonEditProps ) { ) } onChange={ async ( newId: number | string ) => { if ( newId === 'create_new' ) { - setNewCta( true ); + await openNewCTA(); return; } setAttributes( { @@ -945,13 +951,21 @@ function ButtonEdit( props: ButtonEditProps ) { ) } - { typeof newCta === 'number' && newCta > 0 && ( + { ( newCta === 'new' || + ( typeof newCta === 'number' && newCta > 0 ) ) && ( { - setAttributes( { ctaId: values.id } ); + const savedId = + values.id > 0 + ? values.id + : callToActionSelectors.getEditorId(); + + if ( typeof savedId === 'number' && savedId > 0 ) { + setAttributes( { ctaId: savedId } ); + } } } closeOnSave={ true } onClose={ () => { diff --git a/packages/block-library/src/lib/cta-buttons/deprecated.tsx b/packages/block-library/src/lib/cta-buttons/deprecated.tsx index 84cfa9d04..7a23fc31c 100644 --- a/packages/block-library/src/lib/cta-buttons/deprecated.tsx +++ b/packages/block-library/src/lib/cta-buttons/deprecated.tsx @@ -1,3 +1,99 @@ -const deprecated = []; +/** + * External dependencies + */ +import clsx from 'clsx'; +import React from 'react'; + +/** + * WordPress dependencies + */ +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +const legacySupports = { + anchor: true, + align: [ 'wide', 'full' ], + html: false, + __experimentalExposeControlsToChildren: true, + color: { + gradients: true, + text: false, + __experimentalDefaultControls: { + background: true, + }, + }, + spacing: { + blockGap: [ 'horizontal', 'vertical' ], + padding: true, + margin: [ 'top', 'bottom' ], + __experimentalDefaultControls: { + blockGap: true, + }, + }, + typography: { + fontSize: true, + lineHeight: true, + __experimentalFontFamily: true, + __experimentalFontWeight: true, + __experimentalFontStyle: true, + __experimentalTextTransform: true, + __experimentalTextDecoration: true, + __experimentalLetterSpacing: true, + __experimentalDefaultControls: { + fontSize: true, + }, + }, + __experimentalBorder: { + color: true, + radius: true, + style: true, + width: true, + __experimentalDefaultControls: { + color: true, + radius: true, + style: true, + width: true, + }, + }, + layout: { + allowSwitching: false, + allowInheriting: false, + default: { + type: 'flex', + }, + }, + interactivity: { + clientNavigation: true, + }, +}; + +const deprecated = [ + { + apiVersion: 3, + attributes: {}, + supports: legacySupports, + save( { attributes, className } ) { + const { fontSize, style } = attributes; + + const blockProps = useBlockProps.save( { + className: clsx( + className, + // Legacy markup included the core/buttons class so core styles reached children. + 'wp-block-buttons', + { + 'has-custom-font-size': + fontSize || style?.typography?.fontSize, + } + ), + } ); + + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + + return
; + }, + migrate( attributes, innerBlocks ) { + return [ attributes, innerBlocks ]; + }, + }, +]; export default deprecated; diff --git a/packages/block-library/src/lib/cta-buttons/transforms.ts b/packages/block-library/src/lib/cta-buttons/transforms.ts index 675edbd01..cd02ee7cb 100644 --- a/packages/block-library/src/lib/cta-buttons/transforms.ts +++ b/packages/block-library/src/lib/cta-buttons/transforms.ts @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { createBlock } from '@wordpress/blocks'; +import { createBlock, cloneBlock } from '@wordpress/blocks'; import { getTransformedMetadata } from '../utils/get-transformed-metadata'; // import { __unstableCreateElement as createElement } from '@wordpress/rich-text'; @@ -75,15 +75,16 @@ const transforms = { type: 'block', isMultiBlock: true, blocks: [ 'core/buttons' ], - transform: ( buttons ) => - // Creates the cta-buttons block. + // core/buttons is a container; its real content is the inner + // core/button blocks (second arg). Clone those through instead of + // reading the container's own attributes, which would drop them. + transform: ( _buttonsAttributes, innerBlocks ) => createBlock( 'popup-maker/cta-buttons', {}, - // Loop the selected buttons. - buttons.map( ( attributes ) => - createBlock( 'core/button', attributes ) - ) + ( innerBlocks || [] ) + .flat() + .map( ( button ) => cloneBlock( button ) ) ), }, { diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index 32f919637..0bdfed8a3 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -8,7 +8,8 @@ "@popup-maker/components": [ "../components/src" ], "@popup-maker/core-data": [ "../core-data/src" ], "@popup-maker/cta-editor": [ "../cta-editor/src" ], - "@popup-maker/i18n": [ "../i18n/src" ] + "@popup-maker/i18n": [ "../i18n/src" ], + "@popup-maker/icons": [ "../icons/src" ] } }, "include": [ "src/**/*.{ts,tsx}", "src/**/*.json" ], @@ -17,6 +18,9 @@ { "path": "../i18n" }, + { + "path": "../icons" + }, { "path": "../components" }, diff --git a/packages/components/package.json b/packages/components/package.json index 3a2b19ae9..55afc24dc 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -18,7 +18,6 @@ "url": "https://github.com/popupmaker/popup-maker/issues" }, "main": "build/index.js", - "module": "src/index.js", "types": "build-types/index.d.ts", "exports": { ".": { diff --git a/packages/core-data/src/call-to-actions/__tests__/reducer.test.ts b/packages/core-data/src/call-to-actions/__tests__/reducer.test.ts index 6ecdf2e81..5019882b2 100644 --- a/packages/core-data/src/call-to-actions/__tests__/reducer.test.ts +++ b/packages/core-data/src/call-to-actions/__tests__/reducer.test.ts @@ -43,13 +43,17 @@ const mockEditable = ( id: number, title = `CTA ${ id }` ) => describe( 'CTA Reducer', () => { it( 'returns initial state for unknown action', () => { - const state = reducer( undefined, { type: 'UNKNOWN' } as unknown as ReducerAction ); + const state = reducer( undefined, { + type: 'UNKNOWN', + } as unknown as ReducerAction ); expect( state ).toEqual( initialState ); } ); it( 'returns existing state for unknown action', () => { const existing = { ...initialState, editorId: 42 }; - const state = reducer( existing, { type: 'NONSENSE' } as unknown as ReducerAction ); + const state = reducer( existing, { + type: 'NONSENSE', + } as unknown as ReducerAction ); expect( state ).toBe( existing ); } ); @@ -207,16 +211,22 @@ describe( 'CTA Reducer', () => { } ); describe( 'PURGE_RECORD', () => { - // BUG: Same string/number key mismatch as PURGE_RECORDS. - // allIds is purged correctly (number-to-number), but byId is not - // (Object.entries string key vs number id). See BUGS-FOUND-BY-TESTS.md #1. - const stateWithEdits: State = { ...initialState, byId: { 1: mockCta( 1 ), 2: mockCta( 2 ) }, allIds: [ 1, 2 ], editedEntities: { 1: mockEditable( 1 ) }, - editHistory: { 1: [ [ { op: 'replace', path: '/title', value: 'X' } as Operation ] ] }, + editHistory: { + 1: [ + [ + { + op: 'replace', + path: '/title', + value: 'X', + } as Operation, + ], + ], + }, editHistoryIndex: { 1: 0 }, }; @@ -229,14 +239,13 @@ describe( 'CTA Reducer', () => { expect( state.allIds ).toEqual( [ 2 ] ); } ); - it( 'BUG: does NOT remove byId entry (string/number key mismatch)', () => { + it( 'removes the byId entry', () => { const state = reducer( stateWithEdits, { type: PURGE_RECORD, payload: { id: 1 }, } ); - // byId[1] SHOULD be removed but isn't due to the bug. - expect( state.byId[ 1 ] ).toBeDefined(); + expect( state.byId[ 1 ] ).toBeUndefined(); expect( state.byId[ 2 ] ).toBeDefined(); } ); @@ -251,11 +260,6 @@ describe( 'CTA Reducer', () => { } ); describe( 'PURGE_RECORDS', () => { - // BUG: Object.entries() returns string keys but ids array has numbers. - // ids.includes("1") !== ids.includes(1), so byId/editedEntities/editHistory/ - // editHistoryIndex entries are NEVER actually removed. Only allIds is purged - // correctly (number-to-number comparison). See BUGS-FOUND-BY-TESTS.md #1. - it( 'removes from allIds correctly', () => { const existing: State = { ...initialState, @@ -274,7 +278,7 @@ describe( 'CTA Reducer', () => { expect( state.allIds ).toEqual( [ 3 ] ); } ); - it( 'BUG: does NOT remove byId entries (string/number key mismatch)', () => { + it( 'removes byId, editedEntities, editHistory and editHistoryIndex entries', () => { const existing: State = { ...initialState, byId: { 1: mockCta( 1 ), 2: mockCta( 2 ), 3: mockCta( 3 ) }, @@ -289,11 +293,10 @@ describe( 'CTA Reducer', () => { payload: { ids: [ 1, 2 ] }, } ); - // These SHOULD be purged but aren't due to the bug. - expect( Object.keys( state.byId ) ).toEqual( [ '1', '2', '3' ] ); - expect( Object.keys( state.editedEntities ) ).toEqual( [ '1', '2' ] ); - expect( Object.keys( state.editHistory ) ).toEqual( [ '1', '2' ] ); - expect( Object.keys( state.editHistoryIndex ) ).toEqual( [ '1', '2' ] ); + expect( Object.keys( state.byId ) ).toEqual( [ '3' ] ); + expect( Object.keys( state.editedEntities ) ).toEqual( [] ); + expect( Object.keys( state.editHistory ) ).toEqual( [] ); + expect( Object.keys( state.editHistoryIndex ) ).toEqual( [] ); } ); it( 'returns state unchanged for empty ids array', () => { @@ -629,12 +632,12 @@ describe( 'CTA Reducer', () => { // ID 5 should be invalidated. expect( - state.resolutionState[ 'getCallToAction' ]?.[ 5 ] + state.resolutionState.getCallToAction?.[ 5 ] ).toBeUndefined(); // ID 6 should remain. - expect( - state.resolutionState[ 'getCallToAction' ]?.[ 6 ] - ).toEqual( { status: 'SUCCESS' } ); + expect( state.resolutionState.getCallToAction?.[ 6 ] ).toEqual( { + status: 'SUCCESS', + } ); } ); } ); diff --git a/packages/core-data/src/call-to-actions/actions.ts b/packages/core-data/src/call-to-actions/actions.ts index e81d38ed5..cc5d42e2f 100644 --- a/packages/core-data/src/call-to-actions/actions.ts +++ b/packages/core-data/src/call-to-actions/actions.ts @@ -51,6 +51,8 @@ const handleFieldValidationErrors = ( ) => { // Handle field-specific validation errors if ( error?.code === 'rest_invalid_param' && error?.data?.params ) { + let createdFieldNotices = false; + // Clear previous field errors for this CTA if ( ctaId ) { const notices = registry @@ -81,6 +83,7 @@ const handleFieldValidationErrors = ( isDismissible: false, type: 'default', // Prevent auto-dismiss } ); + createdFieldNotices = true; } // Handle additional errors @@ -104,6 +107,7 @@ const handleFieldValidationErrors = ( type: 'default', // Prevent auto-dismiss } ); + createdFieldNotices = true; } } ); @@ -115,9 +119,10 @@ const handleFieldValidationErrors = ( isDismissible: false, type: 'default', // Prevent auto-dismiss } ); + createdFieldNotices = true; } } ); - return true; + return createdFieldNotices; } return false; }; diff --git a/packages/core-data/src/call-to-actions/reducer.ts b/packages/core-data/src/call-to-actions/reducer.ts index 2c0e059f4..80b0ab2ad 100644 --- a/packages/core-data/src/call-to-actions/reducer.ts +++ b/packages/core-data/src/call-to-actions/reducer.ts @@ -335,36 +335,42 @@ export const reducer = ( return state; } + // allIds holds numeric IDs while the object maps are keyed by string, + // so compare against a stringified set to purge from every structure. + const purgeKeys = new Set( + ids.map( ( purgeId ) => String( purgeId ) ) + ); + // Remove the entity from the allIds array. const allIds = state.allIds.filter( - ( _id ) => ! ids.includes( _id ) + ( _id ) => ! purgeKeys.has( String( _id ) ) ); // Remove the entity from the byId object. const byId = Object.fromEntries( Object.entries( state.byId ).filter( - ( [ _id ] ) => ! ids.includes( _id ) + ( [ _id ] ) => ! purgeKeys.has( _id ) ) ); // Remove the entity from the editedEntities object. const editedEntities = Object.fromEntries( Object.entries( state.editedEntities ).filter( - ( [ _id ] ) => ! ids.includes( _id ) + ( [ _id ] ) => ! purgeKeys.has( _id ) ) ); // Remove the entity from the editHistory object. const editHistory = Object.fromEntries( Object.entries( state.editHistory ).filter( - ( [ _id ] ) => ! ids.includes( _id ) + ( [ _id ] ) => ! purgeKeys.has( _id ) ) ); // Remove the entity from the editHistoryIndex object. const editHistoryIndex = Object.fromEntries( Object.entries( state.editHistoryIndex ).filter( - ( [ _id ] ) => ! ids.includes( _id ) + ( [ _id ] ) => ! purgeKeys.has( _id ) ) ); diff --git a/packages/core-data/src/settings/actions.ts b/packages/core-data/src/settings/actions.ts index db8d6c77b..23a3a1c67 100644 --- a/packages/core-data/src/settings/actions.ts +++ b/packages/core-data/src/settings/actions.ts @@ -227,9 +227,11 @@ const resolutionActions = { dispatch( { type: CHANGE_ACTION_STATUS, - actionName, - status, - message, + payload: { + actionName, + status, + message, + }, } ); }, diff --git a/packages/core-data/src/settings/constants.ts b/packages/core-data/src/settings/constants.ts index aa8ad9bf4..510be924f 100644 --- a/packages/core-data/src/settings/constants.ts +++ b/packages/core-data/src/settings/constants.ts @@ -60,7 +60,8 @@ export const defaultValues: Settings = /** * Prefill settings from window global varis if set. */ -const { currentSettings = defaultValues } = popupMakerCoreData ?? {}; +const { currentSettings = defaultValues } = + typeof popupMakerCoreData !== 'undefined' ? popupMakerCoreData : {}; export const initialState: State = { settings: currentSettings, diff --git a/packages/core-data/src/settings/resolvers.ts b/packages/core-data/src/settings/resolvers.ts index b8b8d084d..54969b922 100644 --- a/packages/core-data/src/settings/resolvers.ts +++ b/packages/core-data/src/settings/resolvers.ts @@ -18,20 +18,27 @@ const settingsResolvers = { registry.batch( () => { if ( settings ) { dispatch.hydrate( settings ); + return; } + // Only an empty/missing response is a failure; a successful + // load must not also dispatch an error. dispatch( { type: SETTINGS_FETCH_ERROR, - message: __( - 'An error occurred, settings were not loaded.', - 'popup-maker' - ), + payload: { + message: __( + 'An error occurred, settings were not loaded.', + 'popup-maker' + ), + }, } ); } ); } catch ( error ) { dispatch( { type: SETTINGS_FETCH_ERROR, - message: getErrorMessage( error ), + payload: { + message: getErrorMessage( error ), + }, } ); } }, diff --git a/packages/core-data/src/settings/utils.ts b/packages/core-data/src/settings/utils.ts index 6d95fda86..eab08c6ce 100644 --- a/packages/core-data/src/settings/utils.ts +++ b/packages/core-data/src/settings/utils.ts @@ -1 +1 @@ -export const apiPath = () => 'popup-maker/v2/settings'; +export const apiPath = () => 'settings'; diff --git a/packages/core-data/src/url-search/actions.ts b/packages/core-data/src/url-search/actions.ts index 8020e9dda..05beb85a0 100644 --- a/packages/core-data/src/url-search/actions.ts +++ b/packages/core-data/src/url-search/actions.ts @@ -58,6 +58,7 @@ const searchActions = { ); dispatch.searchSuccess( queryText, results ); + return; } const errorMessage = __( diff --git a/packages/cta-admin/src/App.tsx b/packages/cta-admin/src/App.tsx index 98618c0b2..dbf29387a 100644 --- a/packages/cta-admin/src/App.tsx +++ b/packages/cta-admin/src/App.tsx @@ -27,10 +27,10 @@ const App = (): JSX.Element => { if ( userCanEditCallToActions ) { _views.push( { name: 'call-to-actions', - title: __( 'Call to Actions', 'popup-maker' ), + title: __( 'Calls to Action', 'popup-maker' ), className: 'call-to-actions', - pageTitle: __( 'Popup Maker - Call to Actions', 'popup-maker' ), - heading: __( 'Popup Maker - Call to Actions', 'popup-maker' ), + pageTitle: __( 'Popup Maker - Calls to Action', 'popup-maker' ), + heading: __( 'Popup Maker - Calls to Action', 'popup-maker' ), comp: CallToActionsView, } ); } diff --git a/packages/cta-admin/src/components/list-view/header.tsx b/packages/cta-admin/src/components/list-view/header.tsx index e7bc68407..b1c7096ad 100644 --- a/packages/cta-admin/src/components/list-view/header.tsx +++ b/packages/cta-admin/src/components/list-view/header.tsx @@ -25,7 +25,7 @@ const Header = (): JSX.Element => { return (

- { __( 'Call to Actions', 'popup-maker' ) } + { __( 'Calls to Action', 'popup-maker' ) }

{ isLoading ? ( diff --git a/packages/cta-admin/src/components/list-view/index.tsx b/packages/cta-admin/src/components/list-view/index.tsx index d99e82f7e..835b4efe7 100644 --- a/packages/cta-admin/src/components/list-view/index.tsx +++ b/packages/cta-admin/src/components/list-view/index.tsx @@ -33,7 +33,7 @@ const CallToActionsView = (): JSX.Element => {

{ __( - 'You do not have permission to manage Call To Actions.', + 'You do not have permission to manage Calls to Action.', 'popup-maker' ) } diff --git a/packages/cta-admin/src/components/list/index.tsx b/packages/cta-admin/src/components/list/index.tsx index ad7cf4777..254815ca6 100644 --- a/packages/cta-admin/src/components/list/index.tsx +++ b/packages/cta-admin/src/components/list/index.tsx @@ -72,7 +72,7 @@ const List = (): JSX.Element => { { - const aValue = - // TODO This will use TableColumnRegistry - sortConfig.orderby === 'type' - ? a.settings.type - : a.title.rendered.toLowerCase(); - - const bValue = - // TODO This will use TableColumnRegistry - sortConfig.orderby === 'type' - ? b.settings.type - : b.title.rendered.toLowerCase(); + const getSortValue = ( record: CallToAction< 'edit' > ) => { + switch ( sortConfig.orderby ) { + case 'id': + return record.id; + case 'type': + return record.settings.type; + case 'conversions': + return record.stats?.conversions ?? 0; + case 'title': + default: + return record.title.rendered.toLowerCase(); + } + }; + + const aValue = getSortValue( a ); + const bValue = getSortValue( b ); if ( aValue < bValue ) { return sortConfig.order === SortDirection.ASC ? -1 : 1; diff --git a/packages/cta-admin/src/index.tsx b/packages/cta-admin/src/index.tsx index 6dcffbfd6..fe17fbf88 100644 --- a/packages/cta-admin/src/index.tsx +++ b/packages/cta-admin/src/index.tsx @@ -1,8 +1,10 @@ import './editor.scss'; import { BrowserRouter } from 'react-router-dom'; -import { QueryParamProvider } from '@popup-maker/use-query-params'; -import { ReactRouter6Adapter } from '@popup-maker/use-query-params/adapters/react-router-6'; +import { + QueryParamProvider, + ReactRouter6Adapter, +} from '@popup-maker/use-query-params'; import domReady from '@wordpress/dom-ready'; import { doAction } from '@wordpress/hooks'; diff --git a/packages/cta-admin/src/registries/list-bulk-actions/delete.tsx b/packages/cta-admin/src/registries/list-bulk-actions/delete.tsx index 06302e6ad..cbb3eb4c1 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/delete.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/delete.tsx @@ -67,7 +67,7 @@ const DeleteBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action deleted.', - '%d call to actions deleted.', + '%d calls to action deleted.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx b/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx index 3444893f5..2a5c0cdb9 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/disable.tsx @@ -69,7 +69,7 @@ const DisableBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action disabled.', - '%d call to actions disabled.', + '%d calls to action disabled.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx b/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx index c97de6e5f..c99cc542e 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/enable.tsx @@ -66,7 +66,7 @@ export const EnableBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action enabled.', - '%d call to actions enabled.', + '%d calls to action enabled.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-bulk-actions/export.tsx b/packages/cta-admin/src/registries/list-bulk-actions/export.tsx index 242d93968..837951477 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/export.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/export.tsx @@ -35,7 +35,7 @@ export const ExportBulkAction = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx b/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx index a528777d5..8f04a22f8 100644 --- a/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx +++ b/packages/cta-admin/src/registries/list-bulk-actions/trash.tsx @@ -85,7 +85,7 @@ const TrashBulkAction = (): JSX.Element | null => { // translators: %d: number of items. _n( '%d call to action moved to trash.', - '%d call to actions moved to trash.', + '%d calls to action moved to trash.', count, 'popup-maker' ), diff --git a/packages/cta-admin/src/registries/list-filters/type.tsx b/packages/cta-admin/src/registries/list-filters/type.tsx index c651cb00b..65e9d41a1 100644 --- a/packages/cta-admin/src/registries/list-filters/type.tsx +++ b/packages/cta-admin/src/registries/list-filters/type.tsx @@ -83,7 +83,12 @@ export const TypeFilter = ( {
  - { typeOptionLabels[ filters?.type ?? 'all' ] } + { Object.prototype.hasOwnProperty.call( + typeOptionLabels, + filters?.type ?? 'all' + ) + ? typeOptionLabels[ filters?.type ?? 'all' ] + : typeOptionLabels.all } {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-options/import.tsx b/packages/cta-admin/src/registries/list-options/import.tsx index 4bac12487..7467b724f 100644 --- a/packages/cta-admin/src/registries/list-options/import.tsx +++ b/packages/cta-admin/src/registries/list-options/import.tsx @@ -34,7 +34,7 @@ export const ImportListOption = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import your call to actions from a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import your calls to action from a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-admin/src/registries/list-quick-actions/export.tsx b/packages/cta-admin/src/registries/list-quick-actions/export.tsx index ac5c4d1b2..580e636e7 100644 --- a/packages/cta-admin/src/registries/list-quick-actions/export.tsx +++ b/packages/cta-admin/src/registries/list-quick-actions/export.tsx @@ -51,7 +51,7 @@ export const ExportQuickAction = (): JSX.Element => {

{ __( - 'Popup Maker Pro gives you the power to import & export your call to actions to a JSON file in seconds.', + 'Popup Maker Pro gives you the power to import & export your calls to action to a JSON file in seconds.', 'popup-maker' ) }

diff --git a/packages/cta-editor/src/editor/hocs/with-data-store.tsx b/packages/cta-editor/src/editor/hocs/with-data-store.tsx index acfb163f8..e61a429fb 100644 --- a/packages/cta-editor/src/editor/hocs/with-data-store.tsx +++ b/packages/cta-editor/src/editor/hocs/with-data-store.tsx @@ -19,6 +19,7 @@ import type { CtaEditorId, EditableCta } from '@popup-maker/core-data'; import type { BaseEditorProps } from '../../types'; type Editable = EditableCta; +type SaveAction = 'createCallToAction' | 'updateCallToAction'; /** * Higher Order Component (HOC) that provides data store integration for Call To Action editors. @@ -77,6 +78,7 @@ export const withDataStore = ( */ const [ triedSaving, setTriedSaving ] = useState< boolean >( false ); const saveHandledRef = useRef( false ); + const saveActionRef = useRef< SaveAction | undefined >( undefined ); // Edits for drafts ('new') are stored under record key 0. const recordKey = editorRecordKey( id ); @@ -93,27 +95,37 @@ export const withDataStore = ( values = fullDefaultValues, isEditorActive, isSaving, - savedSuccessfully, + savingAction, + createSavedSuccessfully, + updateSavedSuccessfully, getEditorValues, isLoadingEntity, fetchError, } = useSelect( ( select ) => { const store = select( callToActionStore ); - - const resolutionState = - store.getResolutionState( 'createCallToAction' ) || - store.getResolutionState( 'updateCallToAction' ); + const isUpdating = store.isResolving( 'updateCallToAction' ); + const isCreating = store.isResolving( 'createCallToAction' ); + let currentSavingAction: SaveAction | undefined; + + if ( isUpdating ) { + currentSavingAction = 'updateCallToAction'; + } else if ( isCreating ) { + currentSavingAction = 'createCallToAction'; + } return { values: store.getEditedCallToAction( recordKey ), isEditorActive: store.isEditorActive(), - isSaving: - store.isResolving( 'updateCallToAction' ) || - store.isResolving( 'createCallToAction' ), + isSaving: isUpdating || isCreating, + savingAction: currentSavingAction, getEditorValues: store.getEditedCallToAction, - savedSuccessfully: - resolutionState?.status === DispatchStatus.Success, + createSavedSuccessfully: + store.getResolutionState( 'createCallToAction' ) + .status === DispatchStatus.Success, + updateSavedSuccessfully: + store.getResolutionState( 'updateCallToAction' ) + .status === DispatchStatus.Success, // True while an instant-open editor waits on its record. // Drafts ('new') are seeded locally and never load. // Reading getCallToAction also kicks off its resolver, so @@ -188,16 +200,26 @@ export const withDataStore = ( * Save the CallToAction when the editor is saved. */ useEffect( () => { + let savedSuccessfully = false; + + if ( saveActionRef.current === 'createCallToAction' ) { + savedSuccessfully = createSavedSuccessfully; + } else if ( saveActionRef.current === 'updateCallToAction' ) { + savedSuccessfully = updateSavedSuccessfully; + } + if ( ! triedSaving ) { if ( isSaving ) { setTriedSaving( true ); saveHandledRef.current = false; + saveActionRef.current = savingAction; } return; } if ( savedSuccessfully && ! saveHandledRef.current ) { saveHandledRef.current = true; + saveActionRef.current = undefined; setTriedSaving( false ); // Get the latest CTA from the store after save const latestCta = getEditorValues( values.id ); @@ -206,10 +228,12 @@ export const withDataStore = ( }, [ onSave, triedSaving, - savedSuccessfully, + createSavedSuccessfully, + updateSavedSuccessfully, getEditorValues, values, isSaving, + savingAction, ] ); const hasEdits = useSelect( diff --git a/packages/cta-editor/src/editor/hocs/with-modal.tsx b/packages/cta-editor/src/editor/hocs/with-modal.tsx index 1ff743d05..0693c5ddc 100644 --- a/packages/cta-editor/src/editor/hocs/with-modal.tsx +++ b/packages/cta-editor/src/editor/hocs/with-modal.tsx @@ -6,7 +6,7 @@ import { callToActionStore } from '@popup-maker/core-data'; import { close, link } from '@wordpress/icons'; import { useDispatch, useSelect } from '@wordpress/data'; import { Button, Modal, Spinner } from '@wordpress/components'; -import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo, useState } from '@wordpress/element'; import { EditorHeaderActions, EditorHeaderOptions } from '../components'; import { useAllFieldErrors } from '../../hooks'; @@ -106,21 +106,19 @@ export const withModal = ( [] ); - // values.id doubles as the edits record key (0 for unsaved drafts). + const { saveEditorValues, resetRecordEdits } = + useDispatch( callToActionStore ); + + const { hasAnyError } = useAllFieldErrors(); + const hasEdits = useSelect( ( select ) => typeof values.id === 'number' ? select( callToActionStore ).hasEdits( values.id ) : false, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ values, isSaving ] + [ values.id ] ); - const { saveEditorValues, resetRecordEdits } = - useDispatch( callToActionStore ); - - const { hasAnyError } = useAllFieldErrors(); - /** * Get the modal title based on the CTA state. */ @@ -136,13 +134,6 @@ export const withModal = ( : __( 'New Call to Action', 'popup-maker' ); }, [ modalProps?.title, values?.id, values?.title ] ); - const confirmLoss = () => { - // eslint-disable-next-line no-alert, no-restricted-globals - return window.confirm( - __( 'Changes you made may not be saved.', 'popup-maker' ) - ); - }; - /** * Handle the close event. * @@ -196,7 +187,11 @@ export const withModal = ( try { // Save to the database - await saveEditorValues(); + const saved = await saveEditorValues(); + + if ( ! saved ) { + return; + } // Call the onSave callback if it exists componentProps?.onSave?.( values ); @@ -217,43 +212,6 @@ export const withModal = ( [ closeOnSave, closeModal, storeSelectors, hasAnyError, values ] ); - const { id: valuesId } = values; - - // Set up confirm to close dialogue as well as prevent changing pages in the brower while hasEdits. - useEffect( - () => { - // On beforeunload event, confirm loss of unsaved changes. - const confirmLossOfUnsavedChanges = ( - event: BeforeUnloadEvent - ) => { - if ( hasEdits ) { - if ( confirmLoss() ) { - resetRecordEdits( valuesId ); - } else { - event.preventDefault(); - return false; - } - } - - return true; - }; - - window.addEventListener( - 'beforeunload', - confirmLossOfUnsavedChanges - ); - - return () => { - window.removeEventListener( - 'beforeunload', - confirmLossOfUnsavedChanges - ); - }; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ hasEdits, valuesId ] - ); - return ( <> { confirm && ( diff --git a/packages/cta-editor/src/registries/fields/custom-fields.tsx b/packages/cta-editor/src/registries/fields/custom-fields.tsx index 67f078931..4eb2aec12 100644 --- a/packages/cta-editor/src/registries/fields/custom-fields.tsx +++ b/packages/cta-editor/src/registries/fields/custom-fields.tsx @@ -88,6 +88,16 @@ export const initCustomFields = () => { return value === dependencyValue; } + if ( typeof value === 'number' ) { + if ( + typeof dependencyValue === + 'undefined' + ) { + return value === 0; + } + return value === dependencyValue; + } + return false; } ); }; diff --git a/packages/fields/src/lib/utils.tsx b/packages/fields/src/lib/utils.tsx index 5fac6acda..dfbc2f8f6 100644 --- a/packages/fields/src/lib/utils.tsx +++ b/packages/fields/src/lib/utils.tsx @@ -118,7 +118,10 @@ export const parseOldArgsToProps = ( return fieldProps; case 'html': - return fieldProps; + return { + ...fieldProps, + content: args.type === 'html' ? args.content ?? '' : '', + }; case 'license_key': return fieldProps; @@ -127,7 +130,18 @@ export const parseOldArgsToProps = ( // customselect is a new field type, return as-is return { ...fieldProps, - entityType: fieldProps.entityType ?? 'custom', + entityType: + args.type === 'customselect' + ? args.entityType ?? + args.post_type ?? + args.taxonomy ?? + args.object_key ?? + 'custom' + : 'custom', + ...( args.type === 'customselect' && + typeof args.apiEndpoint !== 'undefined' && { + apiEndpoint: args.apiEndpoint, + } ), }; case 'text': @@ -218,13 +232,14 @@ export const parseOldArgsToProps = ( return { ...fieldProps, entityKind: 'postType', - entityType: args?.post_type ?? 'post', + entityType: args?.post_type ?? args?.object_key ?? 'post', }; } else if ( args.type === 'taxonomyselect' ) { return { ...fieldProps, entityKind: 'taxonomy', - entityType: args?.taxonomy ?? 'category', + entityType: + args?.taxonomy ?? args?.object_key ?? 'category', }; } else if ( args.type === 'userselect' ) { return { @@ -234,10 +249,27 @@ export const parseOldArgsToProps = ( }; } + if ( args?.object_type === 'taxonomy' || args?.taxonomy ) { + return { + ...fieldProps, + entityKind: 'taxonomy', + entityType: + args?.taxonomy ?? args?.object_key ?? 'category', + }; + } + + if ( args?.object_type === 'user' ) { + return { + ...fieldProps, + entityKind: 'user', + entityType: 'user', + }; + } + return { ...fieldProps, entityKind: 'postType', - entityType: 'post', + entityType: args?.post_type ?? args?.object_key ?? 'post', }; case 'textarea': diff --git a/packages/fields/src/types/old-field.ts b/packages/fields/src/types/old-field.ts index 02df99bf8..366a74b63 100644 --- a/packages/fields/src/types/old-field.ts +++ b/packages/fields/src/types/old-field.ts @@ -57,6 +57,10 @@ export interface OldFieldBase { class?: string; classes?: string | string[]; required?: boolean; + object_type?: 'post' | 'post_type' | 'taxonomy' | 'user' | 'custom_entity'; + object_key?: string; + post_type?: string; + taxonomy?: string; meta?: { [ key: string ]: any; }; @@ -158,6 +162,14 @@ export interface OldUserSelectField extends OldObjectSelectField { user_roles?: string[]; } +export interface OldCustomSelectField extends OldFieldBase { + type: 'customselect'; + entityType?: string; + apiEndpoint?: string; + multiple?: boolean; + placeholder?: string; +} + export interface OldCheckboxField extends OldFieldBase { type: 'checkbox'; } @@ -183,6 +195,7 @@ export type OldFieldProps = | OldObjectSelectField | OldPostSelectField | OldTaxnomySelectField + | OldCustomSelectField | OldCheckboxField | OldTextareaField | OldUserSelectField; @@ -196,6 +209,7 @@ export type OldFieldMap = { html: OldHtmlField; checkbox: OldCheckboxField; color: OldColorField; + customselect: OldCustomSelectField; email: OldTextField; hidden: OldHiddenField; license_key: OldLicenseField; diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 5bd74a889..7c75d6a8c 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -47,13 +47,14 @@ export const _n = ( }; export const _nx = ( - text: string, - context: string, + single: string, + plural: string, number: number, + context: string, domain: TextDomain ) => { // eslint-disable-next-line @wordpress/i18n-text-domain, @wordpress/i18n-no-variables - return i18n._nx( text, context, number, domain ); + return i18n._nx( single, plural, number, context, domain ); }; export const isRTL = () => i18n.isRTL(); diff --git a/packages/icons/package.json b/packages/icons/package.json index 44cccc371..4edfbd87d 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -20,7 +20,6 @@ "url": "https://github.com/popupmaker/popup-maker/issues" }, "main": "build/index.js", - "module": "src/index.js", "types": "build-types/index.d.ts", "sideEffects": [ "*.scss", diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 905daa196..4147ff0e7 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,6 +2,14 @@ * Global types for Popup Maker */ +/** + * An icon: a rendered element, a component, or a registered icon name. + */ +export type IconType = + | JSX.Element + | React.ComponentType< Record< string, unknown > > + | string; + /** * The permissions for the current user. */ diff --git a/packages/use-query-params/package.json b/packages/use-query-params/package.json index e32a68408..89471836e 100644 --- a/packages/use-query-params/package.json +++ b/packages/use-query-params/package.json @@ -22,8 +22,7 @@ "exports": { ".": { "types": "./build-types/src/index.d.ts", - "default": "./build/src/index.js", - "adapters": "./build/src/adapters" + "default": "./build/src/index.js" } }, "wpScript": true, diff --git a/packages/use-query-params/src/index.ts b/packages/use-query-params/src/index.ts index a400badc2..9cf8c1dea 100644 --- a/packages/use-query-params/src/index.ts +++ b/packages/use-query-params/src/index.ts @@ -1,2 +1,3 @@ export * from 'use-query-params'; export * from 'serialize-query-params'; +export { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; diff --git a/packages/utils/src/lib/__tests__/omit.test.ts b/packages/utils/src/lib/__tests__/omit.test.ts index d092948e8..266f2ab4f 100644 --- a/packages/utils/src/lib/__tests__/omit.test.ts +++ b/packages/utils/src/lib/__tests__/omit.test.ts @@ -3,37 +3,29 @@ import omit from '../omit'; describe( 'omit', () => { const obj = { a: 1, b: 2, c: 3, d: 4 }; - // BUG: omit() is typed as Omit but actually PICKS the specified keys - // instead of omitting them. It behaves like pick(). See BUGS-FOUND-BY-TESTS.md #3. - // These tests assert the current (buggy) behavior. - - it( 'BUG: returns only the specified keys (picks instead of omitting)', () => { + it( 'omits the specified keys', () => { const result = omit( obj, 'a', 'b' ); - // Should be { c: 3, d: 4 } if it actually omitted. - expect( result ).toEqual( { a: 1, b: 2 } ); + expect( result ).toEqual( { c: 3, d: 4 } ); } ); - it( 'BUG: returns only the single specified key', () => { + it( 'omits a single specified key', () => { const result = omit( obj, 'a' ); - // Should be { b: 2, c: 3, d: 4 } if it actually omitted. - expect( result ).toEqual( { a: 1 } ); + expect( result ).toEqual( { b: 2, c: 3, d: 4 } ); } ); - it( 'returns empty object when no keys are specified', () => { + it( 'returns a shallow copy when no keys are specified', () => { const result = omit( obj ); - expect( result ).toEqual( {} ); + expect( result ).toEqual( { a: 1, b: 2, c: 3, d: 4 } ); } ); - it( 'BUG: returns only the specified key', () => { + it( 'omits another single key', () => { const result = omit( obj, 'c' ); - // Should be { a: 1, b: 2, d: 4 } if it actually omitted. - expect( result ).toEqual( { c: 3 } ); + expect( result ).toEqual( { a: 1, b: 2, d: 4 } ); } ); - it( 'BUG: returns all keys when all specified (acts as identity pick)', () => { + it( 'returns an empty object when all keys are omitted', () => { const result = omit( obj, 'a', 'b', 'c', 'd' ); - // Should be {} if it actually omitted. - expect( result ).toEqual( { a: 1, b: 2, c: 3, d: 4 } ); + expect( result ).toEqual( {} ); } ); it( 'does not mutate the original object', () => { @@ -41,10 +33,9 @@ describe( 'omit', () => { expect( obj ).toEqual( { a: 1, b: 2, c: 3, d: 4 } ); } ); - it( 'BUG: returns only specified keys with string values', () => { + it( 'omits keys with string values', () => { const strObj = { name: 'test', value: 'hello', extra: 'world' }; const result = omit( strObj, 'name', 'value' ); - // Should be { extra: 'world' } if it actually omitted. - expect( result ).toEqual( { name: 'test', value: 'hello' } ); + expect( result ).toEqual( { extra: 'world' } ); } ); } ); diff --git a/packages/utils/src/lib/omit.ts b/packages/utils/src/lib/omit.ts index 78a4f6f87..6f85d64f9 100644 --- a/packages/utils/src/lib/omit.ts +++ b/packages/utils/src/lib/omit.ts @@ -2,16 +2,13 @@ const omit = < T extends object, K extends keyof T >( obj: T, ...keys: K[] ): Omit< T, K > => { - const r: any = {}; - let length = keys.length; + const result = { ...obj } as Record< string, unknown >; - while ( length-- ) { - const key = keys[ length ]; + keys.forEach( ( key ) => { + delete result[ key as string ]; + } ); - r[ key ] = obj[ key ]; - } - - return r; + return result as Omit< T, K >; }; export default omit; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e85ba2f6..017591074 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) '@storybook/react-webpack5': specifier: 8.6.14 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 @@ -280,6 +280,9 @@ importers: '@wordpress/block-editor': specifier: ^15.2.0 version: 15.16.0(@emotion/is-prop-valid@1.4.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@16.26.1(typescript@5.7.3)) + '@wordpress/blocks': + specifier: ^15.2.0 + version: 15.16.0(react@18.3.1) '@wordpress/components': specifier: ^30.2.0 version: 30.9.0(@emotion/is-prop-valid@1.4.0)(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -703,33 +706,6 @@ importers: specifier: ^3.5.32 version: 3.5.34 - packages/extension-build-tools: - dependencies: - '@popup-maker/custom-templated-path-webpack-plugin': - specifier: ^2.6.2 - version: 2.6.2(webpack@5.105.4) - '@popup-maker/dependency-extraction-webpack-plugin': - specifier: ^6.23.1 - version: 6.23.1(webpack@5.105.4) - '@wordpress/scripts': - specifier: '>=27.0.0' - version: 31.8.0(@playwright/test@1.59.1)(@types/eslint@9.6.1)(@types/node@25.5.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint-scss@6.14.0(stylelint@16.26.1(typescript@5.7.3)))(ts-node@10.9.1(@types/node@25.5.2)(typescript@5.7.3))(type-fest@4.41.0)(typescript@5.7.3)(webpack-hot-middleware@2.26.1) - copy-webpack-plugin: - specifier: ^12.0.2 - version: 12.0.2(webpack@5.105.4) - glob: - specifier: ^13.0.6 - version: 13.0.6 - mini-css-extract-plugin: - specifier: ^2.9.2 - version: 2.10.2(webpack@5.105.4) - rtlcss-webpack-plugin: - specifier: ^4.0.7 - version: 4.0.7 - webpack: - specifier: '>=5.0.0' - version: 5.105.4(webpack-cli@5.1.4) - packages/fields: dependencies: '@popup-maker/components': @@ -807,7 +783,7 @@ importers: version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) '@storybook/react-webpack5': specifier: 8.6.14 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) '@storybook/testing-library': specifier: ^0.2.2 version: 0.2.2 @@ -2727,15 +2703,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@popup-maker/custom-templated-path-webpack-plugin@2.6.2': - resolution: {integrity: sha512-tR0DpFM2o1V64NwJgGp9njXVRGbINz+CfcSmiJ1913Y92ik0mGTHikGJxfUkvGZ93QV/R8TPF6wX9IXjV9ifBQ==} - engines: {node: '>=12'} - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - - '@popup-maker/dependency-extraction-webpack-plugin@6.23.1': - resolution: {integrity: sha512-Ca3dj6aY7mAw+YA0qb8RlbylXpP6PjRyi6ZLbUt0+Dri4ZIOOW26wueWhy+q8OI2oawfdu5FJ7Ld1ONCGVVdpg==} - '@preact/signals-core@1.14.1': resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} @@ -6488,10 +6455,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -13223,16 +13186,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@popup-maker/custom-templated-path-webpack-plugin@2.6.2(webpack@5.105.4)': - dependencies: - webpack: 5.105.4(webpack-cli@5.1.4) - - '@popup-maker/dependency-extraction-webpack-plugin@6.23.1(webpack@5.105.4)': - dependencies: - '@wordpress/dependency-extraction-webpack-plugin': 6.43.0(webpack@5.105.4) - transitivePeerDependencies: - - webpack - '@preact/signals-core@1.14.1': {} '@preact/signals@1.3.4(preact@10.29.1)': @@ -13819,7 +13772,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-webpack5@8.6.14(esbuild@0.25.12)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/builder-webpack5@8.6.14(esbuild@0.25.12)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3)) '@types/semver': 7.7.1 @@ -13827,23 +13780,23 @@ snapshots: case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.4.3 constants-browserify: 1.0.0 - css-loader: 6.11.0(webpack@5.105.4(esbuild@0.25.12)) + css-loader: 6.11.0(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) es-module-lexer: 1.7.0 - fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)) - html-webpack-plugin: 5.6.6(webpack@5.105.4(esbuild@0.25.12)) + fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) + html-webpack-plugin: 5.6.6(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) magic-string: 0.30.21 path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.4 storybook: 8.6.17(wp-prettier@3.0.3) - style-loader: 3.3.4(webpack@5.105.4(esbuild@0.25.12)) - terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)) + style-loader: 3.3.4(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) + terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) ts-dedent: 2.2.0 url: 0.11.4 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.105.4(esbuild@0.25.12) - webpack-dev-middleware: 6.1.3(webpack@5.105.4(esbuild@0.25.12)) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) + webpack-dev-middleware: 6.1.3(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) webpack-hot-middleware: 2.26.1 webpack-virtual-modules: 0.6.2 optionalDependencies: @@ -13855,7 +13808,7 @@ snapshots: - uglify-js - webpack-cli - '@storybook/builder-webpack5@8.6.14(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/builder-webpack5@8.6.14(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3)) '@types/semver': 7.7.1 @@ -13947,11 +13900,11 @@ snapshots: dependencies: storybook: 8.6.17(wp-prettier@3.0.3) - '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3)) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) '@types/semver': 7.7.1 find-up: 5.0.0 magic-string: 0.30.21 @@ -13962,7 +13915,7 @@ snapshots: semver: 7.7.4 storybook: 8.6.17(wp-prettier@3.0.3) tsconfig-paths: 4.2.0 - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) optionalDependencies: typescript: 5.7.3 transitivePeerDependencies: @@ -13973,7 +13926,7 @@ snapshots: - uglify-js - webpack-cli - '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3)) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) @@ -14009,7 +13962,7 @@ snapshots: dependencies: storybook: 8.6.17(wp-prettier@3.0.3) - '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12))': + '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4))': dependencies: debug: 4.4.3 endent: 2.1.0 @@ -14019,7 +13972,7 @@ snapshots: react-docgen-typescript: 2.4.0(typescript@5.7.3) tslib: 2.8.1 typescript: 5.7.3 - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) transitivePeerDependencies: - supports-color @@ -14043,10 +13996,10 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.17(wp-prettier@3.0.3) - '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: - '@storybook/builder-webpack5': 8.6.14(esbuild@0.25.12)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) - '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) + '@storybook/builder-webpack5': 8.6.14(esbuild@0.25.12)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) + '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(esbuild@0.25.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14062,10 +14015,10 @@ snapshots: - uglify-js - webpack-cli - '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)': + '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4)': dependencies: - '@storybook/builder-webpack5': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) - '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) + '@storybook/builder-webpack5': 8.6.14(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) + '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3)(webpack-cli@5.1.4) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.17(wp-prettier@3.0.3)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.17(wp-prettier@3.0.3))(typescript@5.7.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -17349,7 +17302,7 @@ snapshots: css-functions-list@3.3.3: {} - css-loader@6.11.0(webpack@5.105.4(esbuild@0.25.12)): + css-loader@6.11.0(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: icss-utils: 5.1.0(postcss@8.5.15) postcss: 8.5.15 @@ -17360,7 +17313,7 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.7.4 optionalDependencies: - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) css-loader@6.11.0(webpack@5.105.4): dependencies: @@ -18480,7 +18433,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@8.0.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)): + fork-ts-checker-webpack-plugin@8.0.0(typescript@5.7.3)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 @@ -18495,7 +18448,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.2 typescript: 5.7.3 - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) fork-ts-checker-webpack-plugin@8.0.0(typescript@5.7.3)(webpack@5.105.4): dependencies: @@ -18655,12 +18608,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -18863,7 +18810,7 @@ snapshots: html-tags@3.3.1: {} - html-webpack-plugin@5.6.6(webpack@5.105.4(esbuild@0.25.12)): + html-webpack-plugin@5.6.6(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -18871,7 +18818,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.3.2 optionalDependencies: - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) html-webpack-plugin@5.6.6(webpack@5.105.4): dependencies: @@ -22166,9 +22113,9 @@ snapshots: stubborn-utils@1.0.2: {} - style-loader@3.3.4(webpack@5.105.4(esbuild@0.25.12)): + style-loader@3.3.4(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) style-loader@3.3.4(webpack@5.105.4): dependencies: @@ -22355,13 +22302,13 @@ snapshots: dependencies: rimraf: 2.6.3 - terser-webpack-plugin@5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)): + terser-webpack-plugin@5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) optionalDependencies: esbuild: 0.25.12 @@ -22818,7 +22765,7 @@ snapshots: schema-utils: 4.3.3 webpack: 5.105.4(webpack-cli@5.1.4) - webpack-dev-middleware@6.1.3(webpack@5.105.4(esbuild@0.25.12)): + webpack-dev-middleware@6.1.3(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -22826,7 +22773,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.105.4(esbuild@0.25.12) + webpack: 5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4) webpack-dev-middleware@6.1.3(webpack@5.105.4): dependencies: @@ -22895,7 +22842,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.105.4(esbuild@0.25.12): + webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -22919,9 +22866,11 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)) + terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.4(esbuild@0.25.12)(webpack-cli@5.1.4)) watchpack: 2.5.1 webpack-sources: 3.3.4 + optionalDependencies: + webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack-dev-server@4.15.2)(webpack@5.105.4) transitivePeerDependencies: - '@swc/core' - esbuild diff --git a/tests/php/tests/TemplateLibrary_Test.php b/tests/php/tests/TemplateLibrary_Test.php new file mode 100644 index 000000000..80e4d9537 --- /dev/null +++ b/tests/php/tests/TemplateLibrary_Test.php @@ -0,0 +1,184 @@ +fresh_service()->get_categories(); + + foreach ( [ 'subscribe', 'sales-promotions', 'announcements', 'lead-capture', 'engagement', 'compliance', 'ecommerce' ] as $slug ) { + $this->assertArrayHasKey( $slug, $categories ); + } + } + + /** + * Built-in free templates load from disk. + */ + public function test_built_in_templates_load() { + $templates = $this->fresh_service()->get_templates(); + + $this->assertArrayHasKey( 'newsletter-signup', $templates ); + $this->assertArrayHasKey( 'welcome-mat', $templates ); + + $free = array_filter( $templates, function ( $template ) { + return 'free' === $template['tier'] && ! empty( $template['content'] ); + } ); + + $this->assertCount( 12, $free, 'All 12 free templates should ship with content.' ); + } + + /** + * Premium templates appear as locked teasers when nothing registers them. + */ + public function test_premium_teasers_backfilled() { + $service = $this->fresh_service(); + $templates = $service->get_templates(); + + $this->assertArrayHasKey( 'exit-intent-offer', $templates ); + $this->assertArrayHasKey( 'cart-abandonment-offer', $templates ); + + $teaser = $templates['exit-intent-offer']; + + $this->assertSame( 'pro', $teaser['tier'] ); + $this->assertFalse( $service->is_insertable( $teaser ) ); + $this->assertStringContainsString( 'utm_content=exit-intent-offer', $teaser['upgrade_url'] ); + + $this->assertSame( 'pro_plus', $templates['cart-abandonment-offer']['tier'] ); + } + + /** + * Filter registrations with content replace teasers of the same slug. + */ + public function test_filter_registration_overrides_teaser() { + add_filter( 'popup_maker/popup_templates', function ( $templates ) { + $templates['exit-intent-offer'] = [ + 'slug' => 'exit-intent-offer', + 'name' => 'Exit Intent Offer', + 'tier' => 'pro', + 'content' => '

Real content

', + ]; + return $templates; + } ); + + $service = $this->fresh_service(); + $template = $service->get_template( 'exit-intent-offer' ); + + $this->assertTrue( $service->is_insertable( $template ) ); + + remove_all_filters( 'popup_maker/popup_templates' ); + } + + /** + * Invalid definitions are dropped or normalized. + */ + public function test_normalization() { + add_filter( 'popup_maker/popup_templates', function ( $templates ) { + $templates['no-name'] = [ 'slug' => 'no-name' ]; + $templates['bad-tier'] = [ + 'slug' => 'bad-tier', + 'name' => 'Bad Tier', + 'tier' => 'platinum', + 'category' => 'not-a-category', + ]; + return $templates; + } ); + + $templates = $this->fresh_service()->get_templates(); + + $this->assertArrayNotHasKey( 'no-name', $templates, 'Templates without a name are dropped.' ); + $this->assertSame( 'free', $templates['bad-tier']['tier'] ); + $this->assertSame( 'engagement', $templates['bad-tier']['category'] ); + + remove_all_filters( 'popup_maker/popup_templates' ); + } + + /** + * Editor data exposes teasers without content and marks them pro-required. + */ + public function test_editor_data_shape() { + $data = $this->fresh_service()->get_editor_data(); + + $this->assertArrayHasKey( 'templates', $data ); + $this->assertArrayHasKey( 'categories', $data ); + + $by_slug = []; + foreach ( $data['templates'] as $template ) { + $by_slug[ $template['slug'] ] = $template; + } + + $this->assertFalse( $by_slug['newsletter-signup']['proRequired'] ); + $this->assertNotEmpty( $by_slug['newsletter-signup']['content'] ); + + $this->assertTrue( $by_slug['exit-intent-offer']['proRequired'] ); + $this->assertSame( '', $by_slug['exit-intent-offer']['content'] ); + $this->assertNotEmpty( $by_slug['exit-intent-offer']['upgradeUrl'] ); + } + + /** + * Every insertable template parses into valid blocks. + */ + public function test_template_content_parses_as_blocks() { + $service = $this->fresh_service(); + + foreach ( $service->get_templates() as $slug => $template ) { + if ( ! $service->is_insertable( $template ) ) { + continue; + } + + $blocks = parse_blocks( $template['content'] ); + + $named = array_filter( $blocks, function ( $block ) { + return ! empty( $block['blockName'] ); + } ); + + $this->assertNotEmpty( $named, "Template {$slug} should parse into named blocks." ); + + // No stray top-level freeform chunks (stray HTML outside block comments). + foreach ( $blocks as $block ) { + if ( null === $block['blockName'] ) { + $this->assertSame( '', trim( implode( '', $block['innerContent'] ) ), "Template {$slug} contains stray non-block content." ); + } + } + } + } + + /** + * Block patterns & categories register for the popup post type. + */ + public function test_block_patterns_registered() { + $controller = \PopupMaker\plugin()->get_controller( 'TemplateLibrary' ); + + $this->assertNotNull( $controller ); + + $controller->register_block_patterns(); + + $this->assertTrue( \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'popup-maker-templates' ) ); + $this->assertTrue( \WP_Block_Patterns_Registry::get_instance()->is_registered( 'popup-maker/newsletter-signup' ) ); + + $pattern = \WP_Block_Patterns_Registry::get_instance()->get_registered( 'popup-maker/newsletter-signup' ); + + $this->assertSame( [ 'popup' ], $pattern['postTypes'] ); + + // Locked teasers must not register as patterns. + $this->assertFalse( \WP_Block_Patterns_Registry::get_instance()->is_registered( 'popup-maker/exit-intent-offer' ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 47588d46e..604744191 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -202,7 +202,9 @@ const config = { }, devServer: { ...( defaultConfig.devServer || {} ), - allowedHosts: 'all', + // Scoped instead of 'all' to keep the Host-header check (DNS-rebinding); + // .local covers Local by Flywheel. + allowedHosts: [ 'localhost', '.local' ], // port: 8887, // Fix for webpack-dev-server proxy configuration issue proxy: undefined, // Remove any inherited proxy configuration that might be causing the array format issue
<# if (isActive) { #><# } else { #><# } #>