diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000..e928f4ed --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,26 @@ +name: PHPUnit + +on: + pull_request: + push: + branches: + - main + +jobs: + phpunit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up php 8.3 + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-interaction --no-progress --no-scripts + + - name: Run PHPUnit + run: composer test diff --git a/.gitignore b/.gitignore index b665b11e..53d57571 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ npm-debug.log /source/assets/compiled/ /source/assets/build/ /source/hot +/.phpunit.cache/ .env docker-compose.override.yml diff --git a/composer.json b/composer.json index 6f696351..963bd94b 100644 --- a/composer.json +++ b/composer.json @@ -10,9 +10,18 @@ "nesbot/carbon": "^3.3", "samdark/sitemap": "^3.0" }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, "autoload": { "psr-4": { - "App\\Listeners\\": "listeners" + "App\\Listeners\\": "listeners", + "App\\Support\\": "support/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" } }, "scripts": { @@ -25,6 +34,9 @@ "dev": [ "npm run watch" ], + "test": [ + "vendor/bin/phpunit --colors=always" + ], "prod": [ "npm run prod" ] diff --git a/composer.lock b/composer.lock index 6c2dd26d..a715018d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec154b608a4ab7f2db5e82b79ba04174", + "content-hash": "caf4441c940ba413fb8cbe58d5ee61ec", "packages": [ { "name": "brick/math", @@ -5653,7 +5653,1789 @@ "time": "2026-04-26T05:33:54+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": { diff --git a/config.php b/config.php index d7649ec5..28bb21ad 100644 --- a/config.php +++ b/config.php @@ -1,5 +1,7 @@ build($wooCommerceConsumerKey, $wooCommerceConsumerSecret); + +$wooCommerceProductCollection = new WooCommerceProductCollection($wooCommerceAuthenticatedHeaders); + return [ 'production' => false, 'matomo_container' => '8jNjdh8C_dev_dc9cf71ee2745d3690156798', @@ -594,6 +604,9 @@ }); }, ], + 'products_wordpress' => [ + 'items' => fn ($page) => $wooCommerceProductCollection->items($page), + ], 'team' => [ 'path' => function($page){ return 'team/' . Str::slug($page->name); diff --git a/docker-compose.yml b/docker-compose.yml index eb6b28ef..03fe6dbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,3 +35,5 @@ services: - CAMPAIGN_ID=${CAMPAIGN_ID:-123} - URL_SUITECRM=${URL_SUITECRM:-123} - URL_SITE=${URL_SITE:-http://localhost} + - WC_CONSUMER_KEY=${WC_CONSUMER_KEY:-} + - WC_CONSUMER_SECRET=${WC_CONSUMER_SECRET:-} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..e0f229db --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + + tests/Unit + + + \ No newline at end of file diff --git a/source/_assets/scss/_pricing.scss b/source/_assets/scss/_pricing.scss index 66a2d5ca..0cedc10c 100644 --- a/source/_assets/scss/_pricing.scss +++ b/source/_assets/scss/_pricing.scss @@ -1,74 +1,173 @@ @use "variables" as *; /* ===== Pricing CSS =====*/ -.ud-single-pricing { - margin-bottom: 40px; - background: var(--white); - border: 2px solid var(--border-light); - border-radius: 8px; - text-align: center; - padding: 45px 30px; - position: relative; - display: flex; - flex-direction: column; - - &.first-item::after, - &.last-item::after { - content: ""; - position: absolute; - width: 55px; - height: 55px; - z-index: 1; - } +#pricing-plans { + .pricing-plan-card { + background-color: var(--pricing-card-bg); + border: 1.5px solid var(--pricing-card-border); + border-radius: 1rem; + box-shadow: 0 2px 12px rgba(15, 110, 86, 0.08); + color: var(--pricing-card-text); + display: flex; + flex-direction: column; + min-height: 100%; + padding: 2rem 1.75rem; + transition: transform 0.15s ease, box-shadow 0.2s ease; + width: 100%; - &.first-item::after { - background: var(--primary-color); - left: -2px; - bottom: -2px; - border-radius: 0px 55px 0px 8px; - } + &:hover { + box-shadow: 0 18px 40px rgba(15, 110, 86, 0.12); + transform: translateY(-2px); + } - &.last-item::after { - background: var(--primary-dark); - top: -2px; - right: -2px; - border-radius: 0px 8px 0px 55px; - } + &__header { + text-align: center; + } - .ud-pricing-header { - h3 { - font-weight: 600; - font-size: 18px; - color: var(--heading-color); - margin-bottom: 8px; + &__name { + color: var(--pricing-card-heading); + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 800; + line-height: 1.15; + margin: 0 0 0.5rem; + } + + &__price-label { + color: var(--pricing-card-muted); + display: block; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + margin-bottom: 0.5rem; + text-transform: uppercase; } - h4 { + + &__price { + color: var(--pricing-card-price); + font-size: clamp(2.2rem, 5vw, 3rem); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1; + margin: 0; + } + + &__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.25rem; + } + + &__meta-item { + align-items: center; + background-color: var(--pricing-card-surface); + border: 1px solid var(--pricing-card-border); + border-radius: 999px; + color: var(--pricing-card-heading); + display: inline-flex; + font-size: 0.78rem; font-weight: 600; - font-size: 26px; - color: var(--primary-color); - margin-bottom: 40px; + gap: 0.25rem; + line-height: 1.4; + padding: 0.4rem 0.75rem; + + strong { + color: inherit; + font-weight: 700; + } } - } - .ud-pricing-tagline { - color: var(--text-muted); - font-size: 15px; - line-height: 1.6; - margin-bottom: 0; - } + &__divider { + border-color: var(--pricing-card-border) !important; + margin: 1.5rem 0; + opacity: 1; + } - .ud-pricing-body { - margin-bottom: 40px; + &__features { + flex: 1; + list-style: none; + margin: 0 0 1.75rem; + padding: 0; + } + + &__feature { + align-items: flex-start; + border-bottom: 1px solid var(--pricing-card-border); + color: var(--pricing-card-text); + display: flex; + gap: 0.75rem; + line-height: 1.65; + padding: 0.75rem 0; + text-align: left; + + &:first-child { + padding-top: 0; + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + } + + &__feature--inherited { + color: var(--pricing-card-heading); + font-weight: 700; + } + + &__check { + color: var(--pricing-card-check) !important; + flex-shrink: 0; + font-weight: 700; + line-height: 1; + margin-top: 0.15rem; + } + + &__button { + background-color: var(--pricing-card-button-bg); + border: 1.5px solid var(--pricing-card-button-border); + border-radius: 999px; + color: var(--pricing-card-button-text); + display: inline-flex; + font-weight: 700; + justify-content: center; + padding: 0.95rem 1.25rem; + text-align: center; + text-decoration: none; + transition: opacity 0.15s ease, transform 0.15s ease; + width: 100%; - li { - font-weight: 500; - font-size: 15px; - margin-bottom: 18px; + &:hover, + &:focus { + color: var(--pricing-card-button-text); + opacity: 0.94; + transform: translateY(-1px); + } } } +} + +#pricing-comparison { + .pricing-comparison-plan-heading { + --bs-table-bg-type: var(--comparison-plan-bg); + --bs-table-color-type: var(--comparison-plan-text); + background-color: var(--comparison-plan-bg); + border-color: var(--comparison-plan-border); + box-shadow: inset 0 0 0 9999px var(--comparison-plan-bg); + color: var(--comparison-plan-text); + font-weight: 700; + vertical-align: middle; + } +} - .ud-pricing-footer { - margin-top: auto; - padding-top: 32px; +@media (max-width: 575.98px) { + #pricing-plans { + .pricing-plan-card { + padding: 1.5rem 1.25rem; + + &__price { + font-size: 2.25rem; + } + } } } diff --git a/source/pricing.blade.php b/source/pricing.blade.php index 0597e201..3a2011a5 100644 --- a/source/pricing.blade.php +++ b/source/pricing.blade.php @@ -1,90 +1,216 @@ --- -title: "LibreSign Plans and Pricing - Commercial Options for Organizations" -description: "LibreSign is preparing commercial plans for organizations that need support, managed services, integrations, and guided adoption. Contact us to discuss your needs." +title: "LibreSign Plans and Pricing - Featured WooCommerce Plans and Commercial Options" +description: "Browse public LibreSign plans and featured WooCommerce subscriptions synchronized during the static site build." --- @extends('_layouts.main') @section('body') + @php + $pricingStyleBuilder = new \App\Support\Pricing\PricingStyleBuilder(); + $pricingPageData = (new \App\Support\Pricing\PricingPageBuilder())->build( + collect($products_wordpress ?? []), + current_path_locale($page) ?: packageDefaultLocale($page), + packageDefaultLocale($page), + ); + $productEntries = $pricingPageData['productEntries']; + $comparisonProducts = $pricingPageData['comparisonProducts']; + $comparisonFeatureGroups = $pricingPageData['comparisonFeatureGroups']; + $detailRows = $pricingPageData['detailRows']; + @endphp + {{-- Hero --}}
-

{{ $page->t("LibreSign Plans and Pricing") }}

-

{{ $page->t("LibreSign is preparing commercial plans for organizations that need support, managed services, cloud infrastructure, integrations, or dedicated guidance.") }}

+

{{ $page->t('Choose your plan and start signing today') }}

+

{{ $page->t('Digital signatures with legal validity, enterprise-grade security, and seamless integration with the systems you already use. No per-user fees. No extra charges per document.') }}

- {{-- Talk to our team --}} -
-
-
-
-

{{ $page->t("Talk to our team") }}

-

- {{ $page->t("While public pricing is not yet available, our team can help you understand the best option for your organization.") }} -

- - {{ $page->t("Talk to our team") }} - -
-
-
-
- - {{-- Available commercial options --}}
-

{{ $page->t("Available commercial options") }}

+

{{ $page->t('Featured products') }}

-
-
-
-

{{ $page->t("Business") }}

-

{{ $page->t("For organizations that need support, guidance, and a tailored LibreSign adoption path.") }}

-
- -
-
+ @forelse ($productEntries as $productEntry) + @php + $product = $productEntry['product']; + $price = $product->price ?? null; + $detailAttributes = $productEntry['detailAttributes'] ?? []; + $hasPriceRange = $product->hasPriceRange ?? false; + $permalink = $product->permalink ?? '#'; + $buttonLabel = !empty($product->buttonLabel) ? $product->buttonLabel : $page->t('View product'); + $cardHighlights = $productEntry['cardHighlights'] ?? []; + $pricingCardStyle = $pricingStyleBuilder->buildCardStyle((array) ($product->pricingCardColors ?? [])); + @endphp +
+
+
+

{{ $product->title }}

+ + @if ($price) +
+ @if ($hasPriceRange) + {{ $page->t('STARTING FROM') }} + @endif + +
{{ $price }}
+
+ @endif +
+ + @if (!empty($detailAttributes)) +
+ @foreach ($detailAttributes as $attribute) + + {{ $attribute['name'] }} + {{ implode(', ', $attribute['values']) }} + + @endforeach +
+ @endif -
-
-
-

{{ $page->t("API Integration") }}

-

{{ $page->t("For organizations that want to integrate LibreSign with existing systems and workflows.") }}

+ @if (!empty($cardHighlights)) +
+ +
    + @foreach ($cardHighlights as $cardHighlight) +
  • + + + @if (($cardHighlight['type'] ?? 'feature') === 'inherited') + {{ sprintf($page->t($cardHighlight['label'] ?? 'Everything in %s and more'), $cardHighlight['context'] ?? '') }} + @else + {{ $cardHighlight['label'] ?? '' }} + @endif + +
  • + @endforeach +
+ @endif + +
- + @empty +
+
+

{{ $page->t('No featured products are available right now. Please contact our team for current commercial options.') }}

-
+ @endforelse +
+
+
-
-
-
-

{{ $page->t("Cloud and Managed Services") }}

-

{{ $page->t("For organizations interested in hosted or managed infrastructure options when available.") }}

+ @if ($comparisonFeatureGroups->isNotEmpty() || $detailRows->isNotEmpty()) +
+
+
+
+
+

{{ $page->t('Compare plans') }}

-
+
+
+ @endif +
+
+
+
+

{{ $page->t('Need more features?') }}

+ + {{ $page->t('Talk to sales') }} + +
diff --git a/support/Pricing/PricingPageBuilder.php b/support/Pricing/PricingPageBuilder.php new file mode 100644 index 00000000..4e0357a8 --- /dev/null +++ b/support/Pricing/PricingPageBuilder.php @@ -0,0 +1,576 @@ +localeVariants($currentLocale); + $defaultLocaleVariants = $this->localeVariants($defaultLocale); + + $productGroups = collect($products) + ->groupBy(fn ($product) => $product->translationGroup ?? (string) ($product->id ?? $product->slug ?? '')) + ->map(fn ($group) => collect($group)->values()); + + $orderedProductEntries = $productGroups + ->map(fn (Collection $group) => $this->buildProductEntry($group, $currentLocaleVariants, $defaultLocaleVariants)) + ->filter() + ->sort(fn (array $leftEntry, array $rightEntry) => $this->compareProductEntries($leftEntry, $rightEntry)) + ->values(); + + $productEntries = $orderedProductEntries + ->map(function (array $productEntry, int $index) use ($orderedProductEntries) { + $previousProductEntry = $index > 0 ? $orderedProductEntries->get($index - 1) : null; + $currentFeatureValues = $this->flattenFeatureValues($productEntry['displayAttributes'] ?? []); + $previousFeatureValues = $previousProductEntry + ? $this->flattenFeatureValues($previousProductEntry['displayAttributes'] ?? []) + : []; + + return [ + 'product' => $productEntry['product'], + 'displayAttributes' => $productEntry['displayAttributes'], + 'detailAttributes' => $this->filterDetailAttributes($productEntry['displayAttributes']), + 'cardHighlights' => $this->buildCardHighlights( + $currentFeatureValues, + $previousFeatureValues, + $previousProductEntry['product']->title ?? null + ), + ]; + }) + ->values(); + + [$comparisonProducts, $comparisonFeatureGroups, $detailRows] = $this->buildComparisonData($productEntries); + + return [ + 'productEntries' => $productEntries, + 'comparisonProducts' => $comparisonProducts, + 'comparisonFeatureGroups' => $comparisonFeatureGroups, + 'detailRows' => $detailRows, + ]; + } + + private function buildProductEntry( + Collection $group, + array $currentLocaleVariants, + array $defaultLocaleVariants, + ): ?array { + $selectedProduct = $this->selectLocalizedProduct($group, $currentLocaleVariants, $defaultLocaleVariants); + + if (empty($selectedProduct)) { + return null; + } + + $displayAttributes = $this->mergeDisplayAttributes( + $this->filterFriendlyAttributes($selectedProduct->attributes ?? []), + $this->filterFriendlyAttributes( + $this->firstMatchingAttributes($group, $currentLocaleVariants)?->attributes ?? [] + ), + $this->filterFriendlyAttributes( + $this->firstMatchingAttributes($group, $defaultLocaleVariants)?->attributes ?? [] + ), + $this->filterFriendlyAttributes( + $group->first(fn ($product) => !empty($this->filterFriendlyAttributes($product->attributes ?? [])))?->attributes ?? [] + ), + ); + + $storageAttribute = $this->resolveGroupAttribute( + $group, + ['storage', 'storage_available', 'available_storage', 'nextcloud_storage'], + $currentLocaleVariants, + $defaultLocaleVariants, + ); + + return [ + 'product' => $selectedProduct, + 'displayAttributes' => $displayAttributes, + 'sortMetrics' => [ + 'storageCapacity' => $this->parseStorageCapacity($storageAttribute), + 'featureCount' => count($this->flattenFeatureValues($displayAttributes)), + 'date' => $selectedProduct->date ?? 0, + ], + ]; + } + + private function buildComparisonData(Collection $productEntries): array + { + $comparisonProducts = []; + $detailAttributeDefinitions = []; + + foreach ($productEntries as $productEntry) { + $selectedProduct = $productEntry['product']; + $displayAttributes = $productEntry['displayAttributes'] ?? []; + $featureGroups = []; + $featureAttributeNamesToHide = $this->collectFeatureAttributeNames($displayAttributes); + $detailValues = []; + + foreach ($this->featureGroupDefinitions() as $featureGroupDefinition) { + $featureGroup = $this->buildFeatureGroup($displayAttributes, $featureGroupDefinition); + + if ($featureGroup === null) { + continue; + } + + $featureGroups[$featureGroupDefinition['key']] = [ + 'label' => $featureGroup['label'], + 'values' => $featureGroup['values'], + 'lookup' => collect($featureGroup['values']) + ->mapWithKeys(function ($featureValue) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + return $normalizedFeatureValue === '' ? [] : [$normalizedFeatureValue => true]; + }) + ->all(), + ]; + } + + foreach ($displayAttributes as $attribute) { + $attributeName = $attribute['name'] ?? ''; + $normalizedAttributeName = $this->normalizeAttributeName($attributeName); + $attributeValues = $this->extractAttributeValues($attribute); + + if ($normalizedAttributeName === '' || empty($attributeValues)) { + continue; + } + + if (in_array($normalizedAttributeName, $featureAttributeNamesToHide, true)) { + continue; + } + + if (!array_key_exists($normalizedAttributeName, $detailAttributeDefinitions)) { + $detailAttributeDefinitions[$normalizedAttributeName] = $attributeName; + } + + $detailValues[$normalizedAttributeName] = $attributeValues; + } + + $comparisonProducts[] = [ + 'title' => $selectedProduct->title ?? '', + 'permalink' => $selectedProduct->permalink ?? '#', + 'pricingCardColors' => (array) ($selectedProduct->pricingCardColors ?? []), + 'featureGroups' => $featureGroups, + 'detailValues' => $detailValues, + ]; + } + + $comparisonProducts = collect($comparisonProducts); + + $comparisonFeatureGroups = collect($this->featureGroupDefinitions()) + ->map(function (array $featureGroupDefinition) use ($comparisonProducts) { + $featureGroupKey = $featureGroupDefinition['key']; + $featureOrder = []; + + foreach ($comparisonProducts as $comparisonProduct) { + foreach ($comparisonProduct['featureGroups'][$featureGroupKey]['values'] ?? [] as $featureValue) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + if ($normalizedFeatureValue === '' || array_key_exists($normalizedFeatureValue, $featureOrder)) { + continue; + } + + $featureOrder[$normalizedFeatureValue] = $featureValue; + } + } + + if (empty($featureOrder)) { + return null; + } + + if (($featureGroupDefinition['comparisonMode'] ?? 'all') === 'differentials' && $comparisonProducts->isNotEmpty()) { + $featureOrder = collect($featureOrder) + ->filter(function ($featureLabel, $featureKey) use ($comparisonProducts, $featureGroupKey) { + $includedCount = $comparisonProducts + ->filter(fn ($comparisonProduct) => !empty($comparisonProduct['featureGroups'][$featureGroupKey]['lookup'][$featureKey])) + ->count(); + + return $includedCount > 0 && $includedCount < $comparisonProducts->count(); + }) + ->all(); + } + + if (empty($featureOrder)) { + return null; + } + + return [ + 'key' => $featureGroupKey, + 'label' => $featureGroupDefinition['label'], + 'rows' => collect($featureOrder) + ->map(function ($featureLabel, $featureKey) use ($comparisonProducts, $featureGroupKey) { + return [ + 'label' => $featureLabel, + 'products' => $comparisonProducts->map(fn ($comparisonProduct) => [ + 'title' => $comparisonProduct['title'], + 'included' => !empty($comparisonProduct['featureGroups'][$featureGroupKey]['lookup'][$featureKey]), + ]), + ]; + }) + ->values(), + ]; + }) + ->filter() + ->values(); + + $detailRows = collect($detailAttributeDefinitions) + ->map(function ($attributeLabel, $attributeKey) use ($comparisonProducts) { + $products = $comparisonProducts->map(fn ($product) => [ + 'title' => $product['title'], + 'values' => $product['detailValues'][$attributeKey] ?? [], + ]); + + return [ + 'label' => $attributeLabel, + 'products' => $products, + ]; + }) + ->filter(fn ($row) => $row['products']->contains(fn ($product) => !empty($product['values']))) + ->values(); + + return [$comparisonProducts, $comparisonFeatureGroups, $detailRows]; + } + + private function filterDetailAttributes(array $attributes): array + { + $hiddenAttributeNames = $this->collectFeatureAttributeNames($attributes); + + return array_values(array_filter($attributes, function ($attribute) use ($hiddenAttributeNames) { + return !in_array($this->normalizeAttributeName($attribute['name'] ?? ''), $hiddenAttributeNames, true); + })); + } + + private function compareProductEntries(array $leftEntry, array $rightEntry): int + { + $leftStorageCapacity = $leftEntry['sortMetrics']['storageCapacity'] ?? null; + $rightStorageCapacity = $rightEntry['sortMetrics']['storageCapacity'] ?? null; + + if ($leftStorageCapacity !== null && $rightStorageCapacity !== null && $leftStorageCapacity !== $rightStorageCapacity) { + return $leftStorageCapacity <=> $rightStorageCapacity; + } + + if ($leftStorageCapacity !== null xor $rightStorageCapacity !== null) { + return $leftStorageCapacity === null ? 1 : -1; + } + + $leftFeatureCount = $leftEntry['sortMetrics']['featureCount'] ?? 0; + $rightFeatureCount = $rightEntry['sortMetrics']['featureCount'] ?? 0; + + if ($leftFeatureCount !== $rightFeatureCount) { + return $leftFeatureCount <=> $rightFeatureCount; + } + + $leftDate = $leftEntry['sortMetrics']['date'] ?? 0; + $rightDate = $rightEntry['sortMetrics']['date'] ?? 0; + + if ($leftDate !== $rightDate) { + return $leftDate <=> $rightDate; + } + + return strcasecmp((string) ($leftEntry['product']->title ?? ''), (string) ($rightEntry['product']->title ?? '')); + } + + private function selectLocalizedProduct(Collection $group, array $currentLocaleVariants, array $defaultLocaleVariants) + { + return $group->first(fn ($product) => $this->matchesLocale($product, $currentLocaleVariants)) + ?? $group->first(fn ($product) => $this->matchesLocale($product, $defaultLocaleVariants)) + ?? $group->first(); + } + + private function firstMatchingAttributes(Collection $group, array $targetLocales) + { + return $group->first(fn ($product) => $this->matchesLocale($product, $targetLocales) && !empty($this->filterFriendlyAttributes($product->attributes ?? []))); + } + + private function localeVariants(?string $locale): array + { + if (empty($locale)) { + return []; + } + + $baseLocale = explode('-', $locale)[0] ?? null; + + return array_values(array_unique(array_filter([ + $locale, + $baseLocale, + ]))); + } + + private function normalizeAttributeName(?string $attributeName): string + { + $attributeName = strtolower(trim((string) $attributeName)); + + return trim((string) preg_replace('/[^a-z0-9]+/', '_', $attributeName), '_'); + } + + private function matchesLocale($product, array $targetLocales): bool + { + $productLocales = array_values(array_unique(array_filter(array_merge( + $this->localeVariants($product->lang ?? null), + $this->localeVariants($product->langSlug ?? null), + )))); + + return !empty(array_intersect($productLocales, $targetLocales)); + } + + private function filterFriendlyAttributes(array $attributes): array + { + return array_values(array_filter($attributes, function ($attribute) { + $attributeName = $this->normalizeAttributeName($attribute['name'] ?? ''); + + return $attributeName !== '' && !str_starts_with($attributeName, 'nextcloud_'); + })); + } + + private function findAttributeByNames($product, array $candidateNames): ?array + { + $attributes = collect($product->attributes ?? []); + $friendlyCandidateNames = array_values(array_filter( + $candidateNames, + fn ($candidateName) => !str_starts_with($candidateName, 'nextcloud_') + )); + + $friendlyMatch = $attributes->first(function ($attribute) use ($friendlyCandidateNames) { + return in_array( + $this->normalizeAttributeName($attribute['name'] ?? ''), + $friendlyCandidateNames, + true + ); + }); + + if (!empty($friendlyMatch['values'])) { + return $friendlyMatch; + } + + $fallbackMatch = $attributes->first(function ($attribute) use ($candidateNames) { + return in_array( + $this->normalizeAttributeName($attribute['name'] ?? ''), + $candidateNames, + true + ); + }); + + return !empty($fallbackMatch['values']) ? $fallbackMatch : null; + } + + private function resolveGroupAttribute( + Collection $group, + array $candidateNames, + array $currentLocaleVariants, + array $defaultLocaleVariants, + ): ?array { + $attributeSource = $group->first(fn ($product) => $this->matchesLocale($product, $currentLocaleVariants) && !empty($this->findAttributeByNames($product, $candidateNames)['values'] ?? [])) + ?? $group->first(fn ($product) => $this->matchesLocale($product, $defaultLocaleVariants) && !empty($this->findAttributeByNames($product, $candidateNames)['values'] ?? [])) + ?? $group->first(fn ($product) => !empty($this->findAttributeByNames($product, $candidateNames)['values'] ?? [])); + + return $attributeSource ? $this->findAttributeByNames($attributeSource, $candidateNames) : null; + } + + private function parseStorageCapacity(?array $attribute): ?float + { + $storageValue = $attribute['values'][0] ?? null; + + if (empty($storageValue) || !preg_match('/([\d.,]+)\s*([kmgtp]?b)/i', (string) $storageValue, $matches)) { + return null; + } + + $normalizedValue = str_replace(['.', ','], ['', '.'], $matches[1]); + $numericValue = (float) $normalizedValue; + $unit = strtoupper($matches[2]); + $multipliers = [ + 'B' => 1, + 'KB' => 1024, + 'MB' => 1024 ** 2, + 'GB' => 1024 ** 3, + 'TB' => 1024 ** 4, + 'PB' => 1024 ** 5, + ]; + + return isset($multipliers[$unit]) ? $numericValue * $multipliers[$unit] : $numericValue; + } + + private function normalizeComparisonValue(?string $value): string + { + $value = trim((string) $value); + + if ($value === '') { + return ''; + } + + return function_exists('mb_strtolower') + ? mb_strtolower($value, 'UTF-8') + : strtolower($value); + } + + private function extractAttributeValues(?array $attribute): array + { + return array_values(array_filter(array_map(static function ($value) { + return trim((string) $value); + }, $attribute['values'] ?? []), static fn ($value) => $value !== '')); + } + + private function mergeDisplayAttributes(array ...$attributeSets): array + { + $mergedAttributes = []; + + foreach ($attributeSets as $attributes) { + foreach ($attributes as $attribute) { + $normalizedAttributeName = $this->normalizeAttributeName($attribute['name'] ?? ''); + $attributeValues = $this->extractAttributeValues($attribute); + + if ($normalizedAttributeName === '' || empty($attributeValues) || array_key_exists($normalizedAttributeName, $mergedAttributes)) { + continue; + } + + $mergedAttributes[$normalizedAttributeName] = [ + 'name' => $attribute['name'] ?? '', + 'values' => $attributeValues, + ]; + } + } + + return array_values($mergedAttributes); + } + + private function featureGroupDefinitions(): array + { + return [ + [ + 'key' => 'libresign_features', + 'candidateNames' => ['libresign_features', 'libresign_feature', 'libresign_capabilities'], + 'label' => 'LibreSign features', + 'comparisonMode' => 'all', + ], + [ + 'key' => 'plan_apps', + 'candidateNames' => ['available_features', 'available_apps', 'apps_available', 'nextcloud_apps'], + 'label' => 'Apps and plan extras', + 'comparisonMode' => 'differentials', + ], + ]; + } + + private function findDisplayAttribute(array $attributes, array $candidateNames): ?array + { + foreach ($attributes as $attribute) { + if (in_array($this->normalizeAttributeName($attribute['name'] ?? ''), $candidateNames, true)) { + return $attribute; + } + } + + return null; + } + + private function buildFeatureGroup(array $attributes, array $featureGroupDefinition): ?array + { + $attribute = $this->findDisplayAttribute($attributes, $featureGroupDefinition['candidateNames'] ?? []); + $values = $this->extractAttributeValues($attribute); + + if (empty($values)) { + return null; + } + + return [ + 'key' => $featureGroupDefinition['key'], + 'attributeName' => $attribute['name'] ?? ($featureGroupDefinition['label'] ?? 'Features'), + 'label' => $featureGroupDefinition['label'] ?? ($attribute['name'] ?? 'Features'), + 'values' => $values, + ]; + } + + private function collectFeatureAttributeNames(array $attributes): array + { + $attributeNames = []; + + foreach ($this->featureGroupDefinitions() as $featureGroupDefinition) { + $attribute = $this->findDisplayAttribute($attributes, $featureGroupDefinition['candidateNames'] ?? []); + $normalizedAttributeName = $this->normalizeAttributeName($attribute['name'] ?? ''); + + if ($normalizedAttributeName !== '') { + $attributeNames[$normalizedAttributeName] = true; + } + } + + return array_keys($attributeNames); + } + + private function flattenFeatureValues(array $attributes): array + { + $featureValues = []; + + foreach ($this->featureGroupDefinitions() as $featureGroupDefinition) { + $featureGroup = $this->buildFeatureGroup($attributes, $featureGroupDefinition); + + if ($featureGroup === null) { + continue; + } + + foreach ($featureGroup['values'] as $featureValue) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + if ($normalizedFeatureValue === '' || array_key_exists($normalizedFeatureValue, $featureValues)) { + continue; + } + + $featureValues[$normalizedFeatureValue] = $featureValue; + } + } + + return array_values($featureValues); + } + + private function buildCardHighlights( + array $currentFeatureValues, + array $previousFeatureValues = [], + ?string $previousProductTitle = null, + ): array { + $currentFeatureLookup = []; + $deduplicatedCurrentValues = []; + + foreach ($currentFeatureValues as $featureValue) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + if ($normalizedFeatureValue === '' || array_key_exists($normalizedFeatureValue, $currentFeatureLookup)) { + continue; + } + + $currentFeatureLookup[$normalizedFeatureValue] = true; + $deduplicatedCurrentValues[] = $featureValue; + } + + $previousFeatureLookup = []; + foreach ($previousFeatureValues as $featureValue) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + if ($normalizedFeatureValue !== '') { + $previousFeatureLookup[$normalizedFeatureValue] = true; + } + } + + $differentialFeatureValues = array_values(array_filter($deduplicatedCurrentValues, function ($featureValue) use ($previousFeatureLookup) { + $normalizedFeatureValue = $this->normalizeComparisonValue($featureValue); + + return $normalizedFeatureValue !== '' && !array_key_exists($normalizedFeatureValue, $previousFeatureLookup); + })); + + if (!empty($differentialFeatureValues) && !empty($previousProductTitle)) { + return array_merge([ + [ + 'type' => 'inherited', + 'label' => 'Everything in %s and more', + 'context' => $previousProductTitle, + ], + ], array_map(static fn ($featureValue) => [ + 'type' => 'feature', + 'label' => $featureValue, + ], $differentialFeatureValues)); + } + + return array_map(static fn ($featureValue) => [ + 'type' => 'feature', + 'label' => $featureValue, + ], $deduplicatedCurrentValues); + } +} diff --git a/support/Pricing/PricingStyleBuilder.php b/support/Pricing/PricingStyleBuilder.php new file mode 100644 index 00000000..e4319bdf --- /dev/null +++ b/support/Pricing/PricingStyleBuilder.php @@ -0,0 +1,109 @@ + + */ + public function defaultPricingCardColors(): array + { + return [ + 'background' => '#FFFFFF', + 'surface' => '#F7FAF8', + 'border' => '#E5E9E7', + 'heading' => '#1A1A1A', + 'price' => '#1A1A1A', + 'text' => '#4A4A4A', + 'muted' => '#717171', + 'accent' => '#1D9E75', + 'check' => '#1D9E75', + 'button_background' => '#FFFFFF', + 'button_text' => '#0F6E56', + 'button_border' => '#0F6E56', + ]; + } + + /** + * @param array $pricingCardColors + */ + public function buildCardStyle(array $pricingCardColors): string + { + $resolvedColors = $this->mergePricingCardColors($pricingCardColors); + + return $this->buildCssVariables([ + '--pricing-card-bg' => $pricingCardColors['background'] ?? $resolvedColors['background'] ?? null, + '--pricing-card-surface' => $pricingCardColors['surface'] + ?? ($pricingCardColors['background'] ?? null) + ?? $resolvedColors['surface'] + ?? ($resolvedColors['background'] ?? null), + '--pricing-card-border' => $pricingCardColors['border'] ?? $resolvedColors['border'] ?? null, + '--pricing-card-heading' => $pricingCardColors['heading'] + ?? ($pricingCardColors['text'] ?? null) + ?? $resolvedColors['heading'] + ?? ($resolvedColors['text'] ?? null), + '--pricing-card-price' => $pricingCardColors['price'] + ?? ($pricingCardColors['heading'] ?? null) + ?? $resolvedColors['price'] + ?? ($resolvedColors['heading'] ?? null), + '--pricing-card-text' => $pricingCardColors['text'] ?? $resolvedColors['text'] ?? null, + '--pricing-card-muted' => $pricingCardColors['muted'] ?? $resolvedColors['muted'] ?? null, + '--pricing-card-accent' => $pricingCardColors['accent'] ?? $resolvedColors['accent'] ?? null, + '--pricing-card-check' => $pricingCardColors['check'] + ?? ($pricingCardColors['accent'] ?? null) + ?? $resolvedColors['check'] + ?? ($resolvedColors['accent'] ?? null), + '--pricing-card-button-bg' => $pricingCardColors['button_background'] ?? $resolvedColors['button_background'] ?? null, + '--pricing-card-button-text' => $pricingCardColors['button_text'] ?? $resolvedColors['button_text'] ?? null, + '--pricing-card-button-border' => $pricingCardColors['button_border'] + ?? ($pricingCardColors['button_background'] ?? null) + ?? $resolvedColors['button_border'] + ?? ($resolvedColors['button_background'] ?? null), + ]); + } + + /** + * @param array $pricingCardColors + */ + public function buildComparisonHeaderStyle(array $pricingCardColors): string + { + $resolvedColors = $this->mergePricingCardColors($pricingCardColors); + + return $this->buildCssVariables([ + '--comparison-plan-bg' => $pricingCardColors['background'] ?? $resolvedColors['background'] ?? null, + '--comparison-plan-text' => $pricingCardColors['price'] + ?? ($pricingCardColors['heading'] ?? ($pricingCardColors['text'] ?? null)) + ?? $resolvedColors['price'] + ?? ($resolvedColors['heading'] ?? ($resolvedColors['text'] ?? null)), + '--comparison-plan-border' => $pricingCardColors['border'] ?? $resolvedColors['border'] ?? null, + ]); + } + + /** + * @param array $pricingCardColors + * @return array + */ + public function mergePricingCardColors(array $pricingCardColors): array + { + return array_merge( + $this->defaultPricingCardColors(), + array_filter($pricingCardColors, static fn ($value) => $value !== null && $value !== '') + ); + } + + /** + * @param array $variables + */ + private function buildCssVariables(array $variables): string + { + return Collection::make($variables) + ->filter(static fn ($value) => $value !== null && $value !== '') + ->map(static fn ($value, $name) => $name . ': ' . $value) + ->implode('; '); + } +} \ No newline at end of file diff --git a/support/Pricing/WooCommerceAuthHeadersBuilder.php b/support/Pricing/WooCommerceAuthHeadersBuilder.php new file mode 100644 index 00000000..9fe02d22 --- /dev/null +++ b/support/Pricing/WooCommerceAuthHeadersBuilder.php @@ -0,0 +1,23 @@ + + */ + public function build(?string $consumerKey, ?string $consumerSecret): array + { + if (empty($consumerKey) || empty($consumerSecret)) { + return []; + } + + return [ + 'Authorization: Basic ' . base64_encode($consumerKey . ':' . $consumerSecret), + 'Accept: application/json', + ]; + } +} \ No newline at end of file diff --git a/support/Pricing/WooCommerceProductCollection.php b/support/Pricing/WooCommerceProductCollection.php new file mode 100644 index 00000000..4e731db5 --- /dev/null +++ b/support/Pricing/WooCommerceProductCollection.php @@ -0,0 +1,118 @@ +transformer = $transformer ?? new WooCommerceProductTransformer(); + } + + public function items($page): Collection + { + if (empty($page->get('accountUrl'))) { + return collect(); + } + + $accountUrl = rtrim((string) $page->get('accountUrl'), '/'); + $productFields = 'id,slug,title,date,lang,translations,link,status'; + $featuredProductsResponse = $this->fetchJson( + $accountUrl . '/wp-json/wp/v2/product?featured=true&per_page=100&_fields=' . $productFields + ); + $languagesResponse = $this->fetchContent($accountUrl . '/wp-json/pll/v1/languages'); + + if ($featuredProductsResponse === null || $languagesResponse === false) { + return collect(); + } + + $featuredProducts = collect($featuredProductsResponse) + ->filter(fn (array $product) => ($product['status'] ?? null) === 'publish') + ->values(); + $wordPressLanguages = json_decode($languagesResponse) ?: []; + + if ($featuredProducts->isEmpty()) { + return collect(); + } + + $localizedProductIds = $featuredProducts + ->flatMap(function (array $product) { + $translationIds = array_values(array_filter($product['translations'] ?? [])); + + return array_values(array_unique(array_merge([ + $product['id'], + ], $translationIds))); + }) + ->unique() + ->values(); + + $localizedProducts = $localizedProductIds + ->chunk(100) + ->flatMap(function (Collection $chunk) use ($accountUrl, $productFields) { + return $this->fetchJson( + $accountUrl + . '/wp-json/wp/v2/product?include=' . implode(',', $chunk->all()) + . '&orderby=include&per_page=100&_fields=' . $productFields + ) ?: []; + }) + ->filter(fn (array $product) => ($product['status'] ?? null) === 'publish') + ->keyBy('id') + ->values(); + + return $localizedProducts + ->map(function (array $fromApi) use ($wordPressLanguages, $accountUrl) { + $productDetails = $this->fetchJson($accountUrl . '/wp-json/wc/store/v1/products/' . $fromApi['id']) ?: []; + $authenticatedProductDetails = !empty($this->authenticatedHeaders) + ? ($this->fetchJson( + $accountUrl . '/wp-json/wc/v3/products/' . $fromApi['id'] . '?_fields=id,attributes', + $this->authenticatedHeaders + ) ?: []) + : []; + + return $this->transformer->mapProduct( + $fromApi, + $productDetails, + $authenticatedProductDetails, + $wordPressLanguages + ); + }) + ->values(); + } + + protected function fetchJson(string $url, array $headers = []): ?array + { + $response = $this->fetchContent($url, $headers); + + return $response ? (json_decode($response, true) ?: null) : null; + } + + protected function fetchContent(string $url, array $headers = []): string|false + { + return @file_get_contents($url, false, $this->createStreamContext($headers)); + } + + protected function createStreamContext(array $headers = []) + { + $contextOptions = [ + 'http' => [ + 'timeout' => $this->timeout, + ], + ]; + + if (!empty($headers)) { + $contextOptions['http']['header'] = implode("\r\n", $headers); + } + + return stream_context_create($contextOptions); + } + +} diff --git a/support/Pricing/WooCommerceProductTransformer.php b/support/Pricing/WooCommerceProductTransformer.php new file mode 100644 index 00000000..4bae68e3 --- /dev/null +++ b/support/Pricing/WooCommerceProductTransformer.php @@ -0,0 +1,178 @@ + 0 ? 10 ** $minorUnit : 1; + $value = ((int) $amount) / $divisor; + + return trim( + ($prices['currency_prefix'] ?? '') + . number_format( + $value, + $minorUnit, + $prices['currency_decimal_separator'] ?? ',', + $prices['currency_thousand_separator'] ?? '.' + ) + . ($prices['currency_suffix'] ?? '') + ); + } + + public function normalizeWooCommerceAttribute(array $attribute): ?array + { + $values = []; + + foreach ($attribute['terms'] ?? [] as $term) { + $values[] = is_array($term) ? ($term['name'] ?? null) : null; + } + + if (empty(array_filter($values))) { + foreach ($attribute['options'] ?? [] as $option) { + if (is_array($option)) { + $values[] = $option['name'] ?? null; + continue; + } + + $values[] = is_string($option) ? $option : null; + } + } + + $values = array_values(array_filter(array_map( + static fn ($value) => trim((string) $value), + $values + ), static fn ($value) => $value !== '')); + + if (empty($values)) { + return null; + } + + return [ + 'name' => $attribute['name'] ?? '', + 'values' => $values, + 'visible' => $attribute['visible'] ?? true, + ]; + } + + public function parsePricingCardColors(array $attributes): array + { + $colorAttribute = collect($attributes)->first(function (array $attribute) { + return in_array( + $this->normalizeAttributeKey($attribute['name'] ?? ''), + ['nextcloud_pricing_card_colors', 'pricing_card_colors'], + true + ); + }); + + if (empty($colorAttribute['values'])) { + return []; + } + + $colors = []; + + foreach ($colorAttribute['values'] as $rawValue) { + [$rawKey, $rawColor] = array_pad(explode(':', (string) $rawValue, 2), 2, null); + $colorKey = $this->normalizeAttributeKey($rawKey); + $colorValue = trim((string) $rawColor); + + if ($colorKey === '' || $colorValue === '') { + continue; + } + + if ($colorValue[0] !== '#') { + $colorValue = '#' . ltrim($colorValue, '#'); + } + + if (!preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?([0-9a-fA-F]{2})?$/', $colorValue)) { + continue; + } + + $colors[$colorKey] = strtoupper($colorValue); + } + + return $colors; + } + + public function mapProduct( + array $fromApi, + array $productDetails, + array $authenticatedProductDetails, + array $wordPressLanguages, + ): array { + $translations = array_filter( + $fromApi['translations'] ?? [], + static fn ($translationId) => !empty($translationId) + ); + $translationIds = array_values($translations); + sort($translationIds); + $translationGroup = implode('-', $translationIds ?: [$fromApi['id']]); + + $currentLang = current(array_filter( + $wordPressLanguages, + static fn ($language) => $language->slug === $fromApi['lang'] + )); + if ($currentLang === false) { + $currentLang = null; + } + + $prices = $productDetails['prices'] ?? []; + $priceRange = $prices['price_range'] ?? null; + $priceAmount = $this->formatWooCommercePrice( + $prices, + $priceRange['min_amount'] ?? $prices['price'] ?? null + ); + + $attributeSource = !empty($authenticatedProductDetails['attributes']) + ? $authenticatedProductDetails['attributes'] + : ($productDetails['attributes'] ?? []); + + $attributes = collect($attributeSource) + ->map(fn (array $attribute) => $this->normalizeWooCommerceAttribute($attribute)) + ->filter() + ->values() + ->all(); + + return [ + 'id' => $fromApi['id'], + 'translationGroup' => $translationGroup, + 'translations' => $translations, + 'title' => $productDetails['name'] ?? $fromApi['title']['rendered'], + 'slug' => $fromApi['slug'], + 'date' => Carbon::parse($fromApi['date'])->timestamp, + 'lang' => $currentLang?->w3c ?? $fromApi['lang'], + 'langSlug' => $currentLang?->slug ?? $fromApi['lang'], + 'description' => !empty($productDetails['short_description']) + ? $productDetails['short_description'] + : ($productDetails['description'] ?? ''), + 'permalink' => $productDetails['permalink'] ?? $fromApi['link'], + 'buttonLabel' => $productDetails['add_to_cart']['single_text'] + ?? $productDetails['add_to_cart']['text'] + ?? 'View product', + 'price' => $priceAmount, + 'hasPriceRange' => !empty($priceRange['min_amount']), + 'productType' => $productDetails['type'] ?? null, + 'isPurchasable' => $productDetails['is_purchasable'] ?? false, + 'hasOptions' => $productDetails['has_options'] ?? false, + 'attributes' => $attributes, + 'pricingCardColors' => $this->parsePricingCardColors($attributes), + ]; + } + + private function normalizeAttributeKey(?string $attributeName): string + { + $attributeName = strtolower(trim((string) $attributeName)); + + return trim((string) preg_replace('/[^a-z0-9]+/', '_', $attributeName), '_'); + } +} diff --git a/tests/Unit/Support/Pricing/PricingPageBuilderTest.php b/tests/Unit/Support/Pricing/PricingPageBuilderTest.php new file mode 100644 index 00000000..5e4f53a1 --- /dev/null +++ b/tests/Unit/Support/Pricing/PricingPageBuilderTest.php @@ -0,0 +1,224 @@ +builder = new PricingPageBuilder(); + } + + #[DataProvider('productOrderingProvider')] + public function testBuildOrdersProductsUsingExpectedPriorityRules(array $products, array $expectedOrder): void + { + $viewModel = $this->builder->build($this->makeProducts($products), 'en-US', 'en-US'); + + self::assertSame( + $expectedOrder, + $viewModel['productEntries']->map(fn (array $entry) => $entry['product']->title)->all(), + ); + } + + public static function productOrderingProvider(): iterable + { + yield 'storage capacity takes precedence' => [ + [ + [ + 'id' => 2, + 'title' => 'Professional', + 'date' => 200, + 'attributes' => [ + ['name' => 'Storage', 'values' => ['120 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ['name' => 'Available features', 'values' => ['Forms']], + ], + ], + [ + 'id' => 3, + 'title' => 'Enterprise', + 'date' => 300, + 'attributes' => [ + ['name' => 'Storage', 'values' => ['800 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ['name' => 'Available features', 'values' => ['Forms', 'Video conference']], + ], + ], + [ + 'id' => 1, + 'title' => 'Basic', + 'date' => 100, + 'attributes' => [ + ['name' => 'Storage', 'values' => ['2 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ], + ], + ], + ['Basic', 'Professional', 'Enterprise'], + ]; + + yield 'feature count and date break ties when storage is missing' => [ + [ + [ + 'id' => 10, + 'title' => 'Advanced', + 'date' => 300, + 'attributes' => [ + ['name' => 'LibreSign features', 'values' => ['A', 'B']], + ], + ], + [ + 'id' => 11, + 'title' => 'Starter', + 'date' => 100, + 'attributes' => [ + ['name' => 'LibreSign features', 'values' => ['A']], + ], + ], + [ + 'id' => 12, + 'title' => 'Pro', + 'date' => 200, + 'attributes' => [ + ['name' => 'LibreSign features', 'values' => ['A', 'B']], + ], + ], + ], + ['Starter', 'Pro', 'Advanced'], + ]; + } + + public function testBuildCreatesInheritedHighlightsAndDifferentialComparisonRows(): void + { + $viewModel = $this->builder->build($this->makeProducts([ + [ + 'id' => 1, + 'title' => 'Basic', + 'attributes' => [ + ['name' => 'Storage', 'values' => ['2 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ], + ], + [ + 'id' => 2, + 'title' => 'Professional', + 'attributes' => [ + ['name' => 'Storage', 'values' => ['120 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ['name' => 'Available features', 'values' => ['Forms']], + ], + ], + [ + 'id' => 3, + 'title' => 'Enterprise', + 'attributes' => [ + ['name' => 'Storage', 'values' => ['800 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ['name' => 'Available features', 'values' => ['Forms', 'Video conference']], + ], + ], + ]), 'en-US', 'en-US'); + + $productEntries = $viewModel['productEntries']->values(); + + self::assertSame([ + [ + 'type' => 'inherited', + 'label' => 'Everything in %s and more', + 'context' => 'Basic', + ], + [ + 'type' => 'feature', + 'label' => 'Forms', + ], + ], $productEntries[1]['cardHighlights']); + + self::assertSame([ + [ + 'type' => 'inherited', + 'label' => 'Everything in %s and more', + 'context' => 'Professional', + ], + [ + 'type' => 'feature', + 'label' => 'Video conference', + ], + ], $productEntries[2]['cardHighlights']); + + $planExtrasGroup = $viewModel['comparisonFeatureGroups']->firstWhere('key', 'plan_apps'); + + self::assertNotNull($planExtrasGroup); + self::assertSame( + ['Forms', 'Video conference'], + $planExtrasGroup['rows']->map(fn (array $row) => $row['label'])->all(), + ); + } + + public function testBuildUsesLocalizedProductAndFallbackAttributes(): void + { + $viewModel = $this->builder->build($this->makeProducts([ + [ + 'id' => 1, + 'translationGroup' => 'basic-group', + 'title' => 'Basic', + 'lang' => 'en-US', + 'langSlug' => 'en', + 'attributes' => [ + ['name' => 'Storage', 'values' => ['2 Gb']], + ['name' => 'LibreSign features', 'values' => ['Legally valid signatures']], + ], + ], + [ + 'id' => 2, + 'translationGroup' => 'basic-group', + 'title' => 'Básico', + 'lang' => 'pt-BR', + 'langSlug' => 'pt-br', + 'attributes' => [], + ], + ]), 'pt-BR', 'en-US'); + + $entry = $viewModel['productEntries']->first(); + + self::assertSame('Básico', $entry['product']->title); + self::assertSame([ + [ + 'name' => 'Storage', + 'values' => ['2 Gb'], + ], + ], $entry['detailAttributes']); + self::assertSame('Storage', $viewModel['detailRows']->first()['label']); + } + + /** + * @param list> $products + */ + private function makeProducts(array $products): Collection + { + return collect(array_map(function (array $product): object { + return (object) array_merge([ + 'id' => 0, + 'translationGroup' => null, + 'title' => 'Untitled', + 'slug' => 'untitled', + 'date' => 0, + 'lang' => 'en-US', + 'langSlug' => 'en', + 'attributes' => [], + 'permalink' => '#', + 'pricingCardColors' => [], + ], $product); + }, $products)); + } +} diff --git a/tests/Unit/Support/Pricing/PricingStyleBuilderTest.php b/tests/Unit/Support/Pricing/PricingStyleBuilderTest.php new file mode 100644 index 00000000..0de5997a --- /dev/null +++ b/tests/Unit/Support/Pricing/PricingStyleBuilderTest.php @@ -0,0 +1,100 @@ +builder = new PricingStyleBuilder(); + } + + #[DataProvider('mergePricingCardColorsProvider')] + public function testMergePricingCardColors(array $colors, array $expectedSubset): void + { + $merged = $this->builder->mergePricingCardColors($colors); + + foreach ($expectedSubset as $key => $value) { + self::assertSame($value, $merged[$key]); + } + } + + public static function mergePricingCardColorsProvider(): iterable + { + yield 'keeps defaults when no custom colors exist' => [ + [], + [ + 'background' => '#FFFFFF', + 'button_text' => '#0F6E56', + ], + ]; + + yield 'overrides defaults with custom values' => [ + [ + 'background' => '#EBF7F2', + 'button_text' => '#FFFFFF', + ], + [ + 'background' => '#EBF7F2', + 'button_text' => '#FFFFFF', + 'border' => '#E5E9E7', + ], + ]; + } + + #[DataProvider('buildCardStyleProvider')] + public function testBuildCardStyle(array $colors, string $expectedFragment): void + { + $style = $this->builder->buildCardStyle($colors); + + self::assertStringContainsString($expectedFragment, $style); + } + + public static function buildCardStyleProvider(): iterable + { + yield 'uses defaults for missing button border' => [ + [ + 'background' => '#EBF7F2', + ], + '--pricing-card-button-border: #0F6E56', + ]; + + yield 'uses custom surface and text colors' => [ + [ + 'surface' => '#D4F0E4', + 'text' => '#1A1A1A', + ], + '--pricing-card-surface: #D4F0E4', + ]; + + yield 'uses custom heading as price fallback when custom price is absent' => [ + [ + 'heading' => '#0F6E56', + ], + '--pricing-card-price: #0F6E56', + ]; + } + + public function testBuildComparisonHeaderStyleUsesFallbackTextColor(): void + { + $style = $this->builder->buildComparisonHeaderStyle([ + 'background' => '#EBF7F2', + 'heading' => '#0F6E56', + 'border' => '#D4F0E4', + ]); + + self::assertStringContainsString('--comparison-plan-bg: #EBF7F2', $style); + self::assertStringContainsString('--comparison-plan-text: #0F6E56', $style); + self::assertStringContainsString('--comparison-plan-border: #D4F0E4', $style); + } +} \ No newline at end of file diff --git a/tests/Unit/Support/Pricing/WooCommerceAuthHeadersBuilderTest.php b/tests/Unit/Support/Pricing/WooCommerceAuthHeadersBuilderTest.php new file mode 100644 index 00000000..faddd3d3 --- /dev/null +++ b/tests/Unit/Support/Pricing/WooCommerceAuthHeadersBuilderTest.php @@ -0,0 +1,51 @@ +builder = new WooCommerceAuthHeadersBuilder(); + } + + #[DataProvider('buildProvider')] + public function testBuild(?string $consumerKey, ?string $consumerSecret, array $expected): void + { + self::assertSame($expected, $this->builder->build($consumerKey, $consumerSecret)); + } + + public static function buildProvider(): iterable + { + yield 'returns empty when key is missing' => [ + null, + 'secret', + [], + ]; + + yield 'returns empty when secret is missing' => [ + 'key', + '', + [], + ]; + + yield 'returns basic auth headers when both credentials are present' => [ + 'ck_test', + 'cs_test', + [ + 'Authorization: Basic ' . base64_encode('ck_test:cs_test'), + 'Accept: application/json', + ], + ]; + } +} diff --git a/tests/Unit/Support/Pricing/WooCommerceProductCollectionTest.php b/tests/Unit/Support/Pricing/WooCommerceProductCollectionTest.php new file mode 100644 index 00000000..831a02b4 --- /dev/null +++ b/tests/Unit/Support/Pricing/WooCommerceProductCollectionTest.php @@ -0,0 +1,232 @@ +items($page)); + self::assertCount(0, $collection->items($page)); + } + + public static function emptyItemsProvider(): iterable + { + yield 'missing account url' => [ + [], + [], + [], + ]; + + yield 'featured products endpoint unavailable' => [ + ['accountUrl' => 'https://account.example.test'], + [], + [], + ]; + + yield 'languages endpoint unavailable' => [ + ['accountUrl' => 'https://account.example.test'], + [ + 'https://account.example.test/wp-json/wp/v2/product?featured=true&per_page=100&_fields=id,slug,title,date,lang,translations,link,status' => [ + ['id' => 10, 'status' => 'publish'], + ], + ], + [], + ]; + + yield 'no published featured products' => [ + ['accountUrl' => 'https://account.example.test'], + [ + 'https://account.example.test/wp-json/wp/v2/product?featured=true&per_page=100&_fields=id,slug,title,date,lang,translations,link,status' => [ + ['id' => 10, 'status' => 'draft'], + ], + ], + [ + 'https://account.example.test/wp-json/pll/v1/languages' => json_encode([]), + ], + ]; + } + + public function testItemsMapsLocalizedProductsUsingAuthenticatedAttributesWithoutMocks(): void + { + $page = new FakeJigsawPage(['accountUrl' => 'https://account.example.test']); + $collection = new FakeWooCommerceProductCollection( + [ + 'https://account.example.test/wp-json/wp/v2/product?featured=true&per_page=100&_fields=id,slug,title,date,lang,translations,link,status' => [ + [ + 'id' => 10, + 'slug' => 'basic', + 'title' => ['rendered' => 'Basic fallback'], + 'date' => '2026-07-03T12:00:00', + 'lang' => 'en', + 'translations' => ['en' => 10, 'pt-br' => 11], + 'link' => 'https://account.example.test/product/basic/', + 'status' => 'publish', + ], + ], + 'https://account.example.test/wp-json/wp/v2/product?include=10,11&orderby=include&per_page=100&_fields=id,slug,title,date,lang,translations,link,status' => [ + [ + 'id' => 10, + 'slug' => 'basic', + 'title' => ['rendered' => 'Basic fallback'], + 'date' => '2026-07-03T12:00:00', + 'lang' => 'en', + 'translations' => ['en' => 10, 'pt-br' => 11], + 'link' => 'https://account.example.test/product/basic/', + 'status' => 'publish', + ], + [ + 'id' => 11, + 'slug' => 'basic-pt', + 'title' => ['rendered' => 'Básico fallback'], + 'date' => '2026-07-03T12:00:00', + 'lang' => 'pt-br', + 'translations' => ['en' => 10, 'pt-br' => 11], + 'link' => 'https://account.example.test/product/basic-pt/', + 'status' => 'publish', + ], + ], + 'https://account.example.test/wp-json/wc/store/v1/products/10' => [ + 'name' => 'Basic', + 'short_description' => '

Short description

', + 'permalink' => 'https://account.example.test/product/basic/', + 'type' => 'simple', + 'is_purchasable' => true, + 'has_options' => false, + 'prices' => [ + 'currency_prefix' => 'R$ ', + 'currency_suffix' => '', + 'currency_minor_unit' => 2, + 'currency_decimal_separator' => ',', + 'currency_thousand_separator' => '.', + 'price' => '5500', + 'price_range' => ['min_amount' => '5500'], + ], + 'add_to_cart' => [ + 'text' => 'View product', + 'single_text' => 'View product', + ], + 'attributes' => [ + [ + 'name' => 'Storage', + 'options' => ['1 Gb'], + 'visible' => true, + ], + ], + ], + 'https://account.example.test/wp-json/wc/v3/products/10?_fields=id,attributes' => [ + 'attributes' => [ + [ + 'name' => 'Storage', + 'options' => ['2 Gb'], + 'visible' => true, + ], + [ + 'name' => 'pricing_card_colors', + 'options' => ['background:#EBF7F2', 'button_text:#FFFFFF'], + 'visible' => true, + ], + ], + ], + 'https://account.example.test/wp-json/wc/store/v1/products/11' => [ + 'name' => 'Básico', + 'short_description' => '

Descrição curta

', + 'permalink' => 'https://account.example.test/product/basic-pt/', + 'type' => 'simple', + 'is_purchasable' => true, + 'has_options' => false, + 'prices' => [ + 'currency_prefix' => 'R$ ', + 'currency_suffix' => '', + 'currency_minor_unit' => 2, + 'currency_decimal_separator' => ',', + 'currency_thousand_separator' => '.', + 'price' => '5500', + 'price_range' => ['min_amount' => '5500'], + ], + 'add_to_cart' => [ + 'text' => 'Ver produto', + 'single_text' => 'Ver produto', + ], + ], + 'https://account.example.test/wp-json/wc/v3/products/11?_fields=id,attributes' => [ + 'attributes' => [], + ], + ], + [ + 'https://account.example.test/wp-json/pll/v1/languages' => json_encode([ + ['slug' => 'en', 'w3c' => 'en-US'], + ['slug' => 'pt-br', 'w3c' => 'pt-BR'], + ]), + ], + ['Authorization: Basic test'], + new WooCommerceProductTransformer(), + ); + + $items = $collection->items($page); + + self::assertCount(2, $items); + self::assertSame('Basic', $items[0]['title']); + self::assertSame('2 Gb', $items[0]['attributes'][0]['values'][0]); + self::assertSame([ + 'background' => '#EBF7F2', + 'button_text' => '#FFFFFF', + ], $items[0]['pricingCardColors']); + self::assertSame('Básico', $items[1]['title']); + self::assertSame('pt-BR', $items[1]['lang']); + } +} + +final class FakeWooCommerceProductCollection extends WooCommerceProductCollection +{ + public function __construct( + private readonly array $jsonResponses, + private readonly array $contentResponses, + array $authenticatedHeaders = [], + ?WooCommerceProductTransformer $transformer = null, + ) { + parent::__construct($authenticatedHeaders, 15, $transformer); + } + + protected function fetchJson(string $url, array $headers = []): ?array + { + return $this->jsonResponses[$url] ?? null; + } + + protected function fetchContent(string $url, array $headers = []): string|false + { + return $this->contentResponses[$url] ?? false; + } +} + +final class FakeJigsawPage +{ + public function __construct(private readonly array $config) + { + } + + public function get(string $key) + { + return $this->config[$key] ?? null; + } +} diff --git a/tests/Unit/Support/Pricing/WooCommerceProductTransformerTest.php b/tests/Unit/Support/Pricing/WooCommerceProductTransformerTest.php new file mode 100644 index 00000000..0209ebff --- /dev/null +++ b/tests/Unit/Support/Pricing/WooCommerceProductTransformerTest.php @@ -0,0 +1,238 @@ +transformer = new WooCommerceProductTransformer(); + } + + #[DataProvider('formatWooCommercePriceProvider')] + public function testFormatWooCommercePrice(array $prices, ?string $amount, ?string $expected): void + { + self::assertSame($expected, $this->transformer->formatWooCommercePrice($prices, $amount)); + } + + public static function formatWooCommercePriceProvider(): iterable + { + yield 'null amount returns null' => [ + ['currency_prefix' => 'R$ ', 'currency_minor_unit' => 2], + null, + null, + ]; + + yield 'formats brazilian currency' => [ + [ + 'currency_prefix' => 'R$ ', + 'currency_suffix' => '', + 'currency_minor_unit' => 2, + 'currency_decimal_separator' => ',', + 'currency_thousand_separator' => '.', + ], + '5500', + 'R$ 55,00', + ]; + + yield 'formats amount without minor unit' => [ + [ + 'currency_prefix' => '', + 'currency_suffix' => ' credits', + 'currency_minor_unit' => 0, + 'currency_decimal_separator' => ',', + 'currency_thousand_separator' => '.', + ], + '1200', + '1.200 credits', + ]; + } + + #[DataProvider('normalizeWooCommerceAttributeProvider')] + public function testNormalizeWooCommerceAttribute(array $attribute, ?array $expected): void + { + self::assertSame($expected, $this->transformer->normalizeWooCommerceAttribute($attribute)); + } + + public static function normalizeWooCommerceAttributeProvider(): iterable + { + yield 'uses terms when available' => [ + [ + 'name' => 'Storage', + 'terms' => [ + ['name' => '2 Gb'], + ['name' => '120 Gb'], + ], + 'visible' => true, + ], + [ + 'name' => 'Storage', + 'values' => ['2 Gb', '120 Gb'], + 'visible' => true, + ], + ]; + + yield 'falls back to string options' => [ + [ + 'name' => 'Term length', + 'options' => ['monthly', 'yearly'], + 'visible' => false, + ], + [ + 'name' => 'Term length', + 'values' => ['monthly', 'yearly'], + 'visible' => false, + ], + ]; + + yield 'returns null for empty values' => [ + [ + 'name' => 'Empty', + 'terms' => [], + 'options' => [' ', null], + ], + null, + ]; + } + + #[DataProvider('parsePricingCardColorsProvider')] + public function testParsePricingCardColors(array $attributes, array $expected): void + { + self::assertSame($expected, $this->transformer->parsePricingCardColors($attributes)); + } + + public static function parsePricingCardColorsProvider(): iterable + { + yield 'parses valid pricing colors' => [ + [ + [ + 'name' => 'Pricing Card Colors', + 'values' => [ + 'background:#0f6e56', + 'button_text:#ffffff', + 'border:D4F0E4', + ], + ], + ], + [ + 'background' => '#0F6E56', + 'button_text' => '#FFFFFF', + 'border' => '#D4F0E4', + ], + ]; + + yield 'ignores invalid color definitions' => [ + [ + [ + 'name' => 'pricing_card_colors', + 'values' => [ + 'background:not-a-color', + 'accent:', + 'check:#12345G', + ], + ], + ], + [], + ]; + + yield 'ignores unrelated attributes' => [ + [ + [ + 'name' => 'Storage', + 'values' => ['2 Gb'], + ], + ], + [], + ]; + } + + public function testMapProductBuildsExpectedPayloadWithoutMocks(): void + { + $fromApi = [ + 'id' => 10, + 'slug' => 'basic', + 'date' => '2026-07-03T12:00:00', + 'lang' => 'en', + 'translations' => ['en' => 10, 'pt-br' => 11], + 'link' => 'https://account.example.test/product/basic/', + 'title' => ['rendered' => 'Basic fallback'], + ]; + $productDetails = [ + 'name' => 'Basic', + 'short_description' => '

Short description

', + 'permalink' => 'https://account.example.test/product/basic/', + 'type' => 'simple', + 'is_purchasable' => true, + 'has_options' => false, + 'prices' => [ + 'currency_prefix' => 'R$ ', + 'currency_suffix' => '', + 'currency_minor_unit' => 2, + 'currency_decimal_separator' => ',', + 'currency_thousand_separator' => '.', + 'price' => '5500', + 'price_range' => ['min_amount' => '5500'], + ], + 'add_to_cart' => [ + 'text' => 'View product', + 'single_text' => 'View product', + ], + 'attributes' => [ + [ + 'name' => 'Storage', + 'options' => ['2 Gb'], + 'visible' => true, + ], + [ + 'name' => 'pricing_card_colors', + 'options' => ['background:#EBF7F2', 'button_text:#FFFFFF'], + 'visible' => true, + ], + ], + ]; + $authenticatedProductDetails = []; + $wordPressLanguages = [ + (object) ['slug' => 'en', 'w3c' => 'en-US'], + ]; + + $result = $this->transformer->mapProduct( + $fromApi, + $productDetails, + $authenticatedProductDetails, + $wordPressLanguages, + ); + + self::assertSame('Basic', $result['title']); + self::assertSame('10-11', $result['translationGroup']); + self::assertSame('R$ 55,00', $result['price']); + self::assertTrue($result['hasPriceRange']); + self::assertSame('en-US', $result['lang']); + self::assertSame('

Short description

', $result['description']); + self::assertSame([ + 'background' => '#EBF7F2', + 'button_text' => '#FFFFFF', + ], $result['pricingCardColors']); + self::assertSame([ + [ + 'name' => 'Storage', + 'values' => ['2 Gb'], + 'visible' => true, + ], + [ + 'name' => 'pricing_card_colors', + 'values' => ['background:#EBF7F2', 'button_text:#FFFFFF'], + 'visible' => true, + ], + ], $result['attributes']); + } +} \ No newline at end of file