diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7505583..2928178 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -193,7 +193,7 @@ jobs: dependency-versions: "${{ matrix.dependencies }}" - name: "Run phpunit" - run: "composer phpunit" + run: "vendor/bin/phpunit --testsuite unit" integration-tests: name: "Integration tests (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }} | SF${{ matrix.symfony }})" @@ -247,7 +247,75 @@ jobs: - name: "Validate Doctrine mapping" run: "(cd tests/Application && bin/console doctrine:schema:validate -vvv)" # The verbose flag will show 'missing' SQL statements, if any + + - name: "Load fixtures" + run: "(cd tests/Application && bin/console sylius:fixtures:load default -n && bin/console sylius:fixtures:load loyalty -n)" + + - name: "Run functional tests" + run: "vendor/bin/phpunit --testsuite functional" + e2e-tests: + name: "End-to-end tests (Playwright)" + + runs-on: "ubuntu-latest" + + strategy: + matrix: + php-version: + - "8.3" + + dependencies: + - "highest" + + steps: + - name: "Start MySQL" + run: "sudo /etc/init.d/mysql start" + + - name: "Checkout" + uses: "actions/checkout@v5" + + - name: "Setup PHP, with composer and extensions" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + extensions: "${{ env.PHP_EXTENSIONS }}" + php-version: "${{ matrix.php-version }}" + + - name: "Install composer dependencies" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Setup node" + uses: "actions/setup-node@v4" + with: + node-version-file: "tests/Application/.nvmrc" + + - name: "Build test application assets" + run: "(cd tests/Application && yarn install && yarn build && APP_ENV=test bin/console assets:install public)" + + - name: "Create database and schema" + run: "(cd tests/Application && APP_ENV=test bin/console doctrine:database:create -n && APP_ENV=test bin/console doctrine:schema:create -n)" + + - name: "Load fixtures" + run: "(cd tests/Application && APP_ENV=test bin/console sylius:fixtures:load default -n && APP_ENV=test bin/console sylius:fixtures:load loyalty -n)" + + - name: "Install Playwright" + run: "(cd e2e && npm ci && npx playwright install --with-deps chromium)" + + - name: "Run Playwright tests" + run: "(cd e2e && npx playwright test)" + + - name: "Upload Playwright report" + uses: "actions/upload-artifact@v4" + if: "failure()" + with: + name: "playwright-report" + path: | + e2e/playwright-report + e2e/test-results + retention-days: 7 + mutation-tests: name: "Mutation tests" @@ -312,7 +380,7 @@ jobs: dependency-versions: "${{ matrix.dependencies }}" - name: "Collect code coverage with pcov and phpunit/phpunit" - run: "vendor/bin/phpunit --coverage-clover=.build/logs/clover.xml" + run: "vendor/bin/phpunit --testsuite unit --coverage-clover=.build/logs/clover.xml" - name: "Send code coverage report to Codecov.io" uses: "codecov/codecov-action@v4" diff --git a/CLAUDE.md b/CLAUDE.md index 3c7493f..d12ba62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -TODO +`setono/sylius-loyalty-plugin` is a loyalty plugin for Sylius 1.14 (PHP >= 8.1, Symfony ^6.4, Doctrine ORM, MySQL 8). It provides: + +- **Points** (Phase 1): event-driven earning on order/payment lifecycle and customer actions, redemption at checkout as a distributed discount adjustment, expiration, and clawback on cancellation/refund — all backed by a strictly **append-only ledger**. +- **Tiers** (Phase 2): admin-created tiers with configurable qualification bases, earning multipliers (applied after all rules, before rounding), a tier-gated promotion rule checker (`customer_loyalty_tier`), shop progress indicators with a top-tier celebration state, and variant-aware earn hints on product and cart pages (never-overstate filtering via condition evaluability metadata). +- **Referrals** (Phase 3): lazy Crockford-base32 codes, `/r/{code}` landing + `?ref=` query-param attribution (30-day cookie, last click wins), pending referral at registration, first-post-attribution-order qualification at the award moment, extensible fraud checks, dual rewards via `earn_referral` (idempotent per account+referral), dual clawback on cancel/refund of the qualifying order. + +Guiding principles: points are a financial liability, so the ledger is the source of truth and balances are derived caches; all earning is idempotent by construction (DB-level unique constraints); everything is channel-aware; every meaningful action dispatches an event; extension points are tagged services or config-registered event classes. B2C only — no cashback, badges/challenges/leaderboards, B2B accounts, emails, or API Platform endpoints. + +## Domain Glossary + +- **Loyalty account**: one per (customer, channel). Holds a cached `balance` and `lifetimeEarned` — both derived from the ledger, never hand-edited. +- **Ledger**: the append-only list of `LoyaltyTransaction` rows (Doctrine single-table inheritance). Rows are never updated or deleted; corrections are new compensating entries. +- **Lot**: a credit transaction with an optional `expiresAt`. There is no stored "remaining" column — per-lot remainders are **derived** by replay. +- **Replay**: deterministic processing of an account's ledger in `occurredAt ASC, id ASC` order; debits consume open lots FIFO in consumption order `expiresAt ASC NULLS LAST, occurredAt ASC, id ASC`. +- **Clawback**: a debit written when an order that earned points is cancelled/refunded, referencing the original earn credit. +- **Requested vs applied points**: `order.loyaltyPointsRequested` is the customer's persisted intent; on every order recalculation the applied amount is derived as `min(requested, balance, cap)` clamped to a clean multiple of the conversion ratio. The checkout debit is always the **applied** amount. +- **Dry run**: an earning rule mode that evaluates against live traffic and logs would-be awards to an audit table instead of writing ledger entries. ## Code Standards @@ -16,54 +32,74 @@ Follow clean code principles and SOLID design patterns when working with this co - Favor composition over inheritance - Write code that is easy to test and extend +Plugin-specific conventions: +- **Services** are declared in XML under `src/Resources/config/services/`, loaded from `services.xml`. Service ids are FQCNs; every service with an interface gets an interface **alias**, and consumers are wired against the interface id so projects can swap implementations by re-aliasing. No autowiring inside the plugin — explicit `` wiring. +- **API surface**: all classes `final` except entities; interfaces are the only supported extension contracts; anything not documented as an extension point is `@internal`. +- **Models** live in `src/Model/` as interface + default implementation, registered under the `setono_sylius_loyalty.resources` config node; Doctrine mappings are XML in `src/Resources/config/doctrine/model/`. Only Doctrine ORM is supported. +- **Database tables** are prefixed `setono_sylius_loyalty__`. +- **No Doctrine migrations ship** — the XML mappings are the schema's source of truth; host projects generate their own via `doctrine:migrations:diff`. +- All ledger writes go through the `LoyaltyLedger` service (pessimistic locking via Doctrine's `LockMode::PESSIMISTIC_WRITE` inside `wrapInTransaction()`); never write transaction rows directly. + ### Testing Requirements -- Write unit tests for all new functionality (if it makes sense) -- Follow the BDD-style naming convention for test methods (e.g., `it_should_do_something_when_condition_is_met`) + +Policy: **all code is unit tested; behavior that touches the framework or database is additionally functionally tested; everything with a UI is covered by Playwright.** The levels complement — never substitute for — each other. + +- **Unit tests** (`tests/Unit/`, plus `tests/DependencyInjection/`): baseline for every class. BDD-style method names (e.g. `it_awards_points_once_for_a_paid_order`). - **MUST use Prophecy for mocking** - Use the `ProphecyTrait` and `$this->prophesize()` for all mocks, NOT PHPUnit's `$this->createMock()` -- **Form testing** - Use Symfony's best practices for form testing as documented at https://symfony.com/doc/current/form/unit_testing.html - - Extend `Symfony\Component\Form\Test\TypeTestCase` for form type tests - - Use `$this->factory->create()` to create form instances - - Test form submission, validation, and data transformation -- Ensure tests are isolated and don't depend on external state -- Test both happy path and edge cases +- **Form testing** - Extend `Symfony\Component\Form\Test\TypeTestCase`, use `$this->factory->create()`, test submission, validation, and data transformation (see https://symfony.com/doc/current/form/unit_testing.html) +- **Functional tests** (`tests/Functional/`): run against the Sylius test app in `tests/Application/` with MySQL. Extend `Setono\SyliusLoyaltyPlugin\Tests\Functional\FunctionalTestCase`; database state is isolated per test by dama/doctrine-test-bundle. Tests that need real commits (cross-process locking/concurrency) opt out via `StaticDriver` and clean up after themselves. +- **E2E tests** (`e2e/`): the committed Playwright suite is the acceptance gate for all UI behavior. +- Ensure tests are isolated and don't depend on external state; test both happy path and edge cases. ### UI Verification - **All UI changes MUST be verified using the Playwright MCP** - After making any change that affects the rendered UI (templates, forms, styling, layout, flash messages, etc.), use the Playwright MCP to navigate the running test application and confirm the change renders and behaves as expected -- Run the test application (see [Test Application](#test-application)) and use the Playwright MCP `browser_navigate`, `browser_snapshot`, and `browser_take_screenshot` tools to inspect the affected pages -- Verify both the visual result and the interactive behavior (e.g. submitting forms, triggering flash messages) +- Verify both the visual result and the interactive behavior (e.g. submitting forms, triggering flash messages), then lock the behavior in as a committed spec in `e2e/`. ## Development Commands -Based on the `composer.json` scripts section: - ### Code Quality & Testing -- `composer analyse` - Run PHPStan static analysis (level 8) -- `composer check-style` - Check code style with ECS (Easy Coding Standard) -- `composer fix-style` - Fix code style issues automatically with ECS -- `composer phpunit` - Run PHPUnit tests - -### Static Analysis - -#### PHPStan Configuration -PHPStan is configured in `phpstan.neon` with: -- **Analysis Level**: max (strictest) -- **Extensions**: Auto-loaded via `phpstan/extension-installer` - - `phpstan/phpstan-symfony` - Symfony framework integration - - `phpstan/phpstan-doctrine` - Doctrine ORM integration - - `phpstan/phpstan-phpunit` - PHPUnit test integration - - `jangregor/phpstan-prophecy` - Prophecy mocking integration -- **Symfony Integration**: Uses console application loader (`tests/console_application.php`) -- **Doctrine Integration**: Uses object manager loader (`tests/object_manager.php`) -- **Exclusions**: Test application directory and Configuration.php -- **Baseline**: Generate with `composer analyse -- --generate-baseline` to track improvements +- `composer analyse` - Run PHPStan static analysis (level max) +- `composer check-style` / `composer fix-style` - Check/fix code style with ECS +- `vendor/bin/phpunit --testsuite unit` - Run unit tests (no database needed) +- `vendor/bin/phpunit --testsuite functional` - Run functional tests (needs the test app database, `APP_ENV=test`) +- `vendor/bin/rector --dry-run` - Rector check (CI runs this) +- `vendor/bin/infection` - Mutation tests (scoped to the unit suite; MSI thresholds are unset until the plugin is feature-complete) +- `(cd e2e && npx playwright test)` - Run the e2e suite (needs the test app served with seeded fixtures; the config boots `php -S` itself) + +### PHPStan Configuration +PHPStan runs at **level max** over `src` and `tests` with the Symfony/Doctrine/PHPUnit/Prophecy extensions (auto-loaded via `phpstan/extension-installer`). Loaders live under `tests/PHPStan/` (`console_application.php`, `object_manager.php`); the test application directory is excluded. ### Test Application -The plugin includes a test Symfony application in `tests/Application/` for development and testing: -- Navigate to `tests/Application/` directory -- Run `yarn install && yarn build` to build assets -- Use standard Symfony commands for the test app +The plugin includes a Sylius test application in `tests/Application/`: +- Use **Node 20** (`.nvmrc`; the Sylius UI toolchain is incompatible with Node 22): `nvm use && yarn install && yarn build` +- Database (MySQL/MariaDB on 127.0.0.1, see `.env`): `APP_ENV=test bin/console doctrine:database:create && APP_ENV=test bin/console doctrine:schema:create && APP_ENV=test bin/console sylius:fixtures:load default -n` +- Serve with `APP_ENV=test php -S 127.0.0.1:8080 -t public` (or `symfony serve`) - **Sylius Backend Credentials**: Username: `sylius`, Password: `sylius` +### Console Commands (shipped by the plugin) +- `setono:sylius-loyalty:trigger-birthdays` - fires the customer_birthday earning trigger for customers whose birthday is today (cron, daily; source identifier `birthday:` makes re-runs no-ops) +- `setono:sylius-loyalty:prune-dry-run-results [--days=30]` - prunes dry-run audit rows (cron, daily) +- `setono:sylius-loyalty:expire-points [--batch-size=100]` - writes expiration entries for lots past expiry, incl. zero-point closers (cron, daily) +- `setono:sylius-loyalty:verify-ledger [--batch-size=100]` - checks the ledger invariants; non-zero exit on violations, never fixes (cron-able) +- `setono:sylius-loyalty:recalculate-balances [--batch-size=100] [--force]` - reports balance drift vs the ledger sum; `--force` corrects it +- `setono:sylius-loyalty:inspect-account [--format=txt|json]` - ledger + replay-derived lot states + invariant check for one account +- `setono:sylius-loyalty:export-customer-data ` - GDPR JSON export of a customer's loyalty data +- Also document in host crons: Sylius core's `sylius:cancel-unpaid-orders` (cancellation triggers redemption rollback + clawback) + +## Extension Points + +Every extension interface is registered for autoconfiguration — implementing the interface is the entire integration surface. + +- **Earning condition types**: implement `EarningRule\Checker\ConditionCheckerInterface` (tag `setono_sylius_loyalty.earning_condition`). +- **Amount types**: implement `EarningRule\Amount\AmountCalculatorInterface` (tag `setono_sylius_loyalty.earning_amount`). +- **Expression functions**: implement `Expression\Function\ExpressionFunctionInterface` (tag `setono_sylius_loyalty.expression_function`); metadata feeds autocompletion and the reference panel automatically. +- **Earning triggers**: create an event class extending `Event\Trigger\EarningTriggerEvent`, register it under `setono_sylius_loyalty.triggers`, and fire it with a plain event dispatch. The subclass's public readonly properties become typed expression context. `sourceIdentifier` defaults to the trigger code ("once per account, ever"); repeatable triggers pass their own. +- **Custom transaction types**: subclass `CreditLoyaltyTransaction`/`DebitLoyaltyTransaction`, implement `getDiscriminator()`, and register the class as a Sylius resource — the discriminator map picks it up automatically. +- **Channel resolution for triggers**: re-alias `Resolver\TriggerChannelResolverInterface`. +- **Tier qualification bases**: implement `Tier\QualificationBasis\TierQualificationBasisInterface` (tag `setono_sylius_loyalty.tier_qualification_basis`); the tier form's basis select and unit hint update automatically. +- **Partial-refund clawback**: call `Ledger\LoyaltyLedgerInterface::clawback(OrderInterface $order, int $points)` from any refund mechanism. +- **Referral fraud checks**: implement `Referral\FraudCheck\ReferralFraudCheckInterface` (tag `setono_sylius_loyalty.referral_fraud_check`); any returned flag rejects the referral, admins can override. + ## Bash Tools Recommendations Use the right tool for the right job when executing bash commands: @@ -75,22 +111,12 @@ Use the right tool for the right job when executing bash commands: - **Interacting with JSON?** → Use `jq` (JSON processor) - **Interacting with YAML or XML?** → Use `yq` (YAML/XML processor) -Examples: -- `fd "*.php" | fzf` - Find PHP files and interactively select one -- `rg "function.*validate" | fzf` - Search for validation functions and select -- `ast-grep --lang php -p 'class $name extends $parent'` - Find class inheritance patterns - ## Architecture Overview ### Translations -The plugin provides multilingual support through translation files in `src/Resources/translations/`: - -- **Translation Files**: Available in 10 languages (en, da, de, es, fr, it, nl, no, pl, sv) -- **Translation Domains**: - - `messages.*` - General UI translations - - `flashes.*` - Flash message translations (success/error messages) +Customer- and admin-facing strings are translatable via `src/Resources/translations/` (domains `messages`, `flashes`, `validators`). English is authored first and is authoritative; the shipped locale set is en, da, sv, no, fi, de, fr, es, it, nl, pl, pt, cs, hu, ro, uk. Generated locale files carry a header comment marking them machine-translated pending native review. Key namespaces: `setono_sylius_loyalty.ui.*`, `setono_sylius_loyalty.form.*`. -Key translation keys: -- `setono_sylius_loyalty.ui.*` - UI labels -- `setono_sylius_loyalty.form.*` - Form field labels -- `setono_sylius_loyalty.single_message` - A flash message +### Known deviations from the original specification +- The product-page earn hint renders via the `sylius.shop.product.show.add_to_cart_form` template event (just above the add-to-cart button) — Sylius 1.14 has no template event directly below the button. +- The order-payment `pay` transition lives on the `sylius_order_payment` state machine graph (not `sylius_payment`). +- Doctrine mappings live in `src/Resources/config/doctrine/model/` (the `AbstractResourceBundle` default). diff --git a/LICENSE b/LICENSE index e4c0848..84b974a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Setono +Copyright (c) 2026 Setono Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index aaac84e..63b8c2d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Setono SyliusLoyaltyPlugin +# Sylius Loyalty Plugin [![Latest Version][ico-version]][link-packagist] [![Software License][ico-license]](LICENSE) @@ -6,40 +6,195 @@ [![Code Coverage][ico-code-coverage]][link-code-coverage] [![Mutation testing][ico-infection]][link-infection] -[Setono](https://setono.com) have made a bunch of [plugins for Sylius](https://github.com/Setono?q=plugin&sort=stargazers), and we have some guidelines -which we try to follow when developing plugins. These guidelines are used in this repository, and it gives you a very -solid base when developing plugins. - -Enjoy! - -## Quickstart - -1. Run - ```shell - composer create-project --prefer-source --no-install --remove-vcs setono/sylius-loyalty-plugin:dev-master ProjectName - ``` - or just click the `Use this template` button at the right corner of this repository. -2. Run - ```shell - cd ProjectName && composer install - ``` -3. From the plugin skeleton root directory, run the following commands: - - ```bash - php init - (cd tests/Application && yarn install) - (cd tests/Application && yarn build) - (cd tests/Application && bin/console assets:install) - - (cd tests/Application && bin/console doctrine:database:create) - (cd tests/Application && bin/console doctrine:schema:create) - - (cd tests/Application && bin/console sylius:fixtures:load -n) - ``` - -4. Start your local PHP server: `symfony serve` (see https://symfony.com/doc/current/setup/symfony_server.html for docs) - -To be able to set up a plugin's database, remember to configure you database credentials in `tests/Application/.env` and `tests/Application/.env.test`. +A loyalty program for [Sylius](https://sylius.com): **points** earned on orders and customer actions through a flexible +rule engine, **redeemed at checkout** as a real discount, with expiration, clawback, and a strictly append-only ledger +as the source of truth. Tiers (Phase 2) and referrals (Phase 3) build on the same core. + +## Highlights + +- **Append-only ledger** — points are treated as a financial liability. Every movement is a transaction row; balances + are derived caches, verifiable with `setono:sylius-loyalty:verify-ledger`. Nothing is ever updated or deleted; + corrections are compensating entries. +- **Rule engine** — earning rules with triggers (order, registration, review approval, birthday, or your own), + conditions, scopes (order / taxon / product with exclusive item claiming), stacking, priorities, dry-run mode, and a + sandboxed expression language with an admin code editor (autocompletion, inline linting, reference panel). +- **Checkout redemption** — customers apply points on the cart; the discount distributes to unit-level adjustments so + taxes and promotions compute correctly. The customer's request is persisted and re-clamped on every order change — + it grows back automatically when the cart allows it. +- **Idempotent by construction** — awarding is guarded by database unique constraints, so double event dispatches, + message redeliveries, and concurrent processes can never double-credit or overspend (pessimistic locking on every + ledger write). +- **Channel-aware** — one account and one program configuration per (customer, channel). +- **Operable** — console commands for expiry, ledger verification, balance recalculation, account inspection, and GDPR + export; an admin ledger inspector with replay-derived lot states and invariant checks. + +## Installation + +```bash +composer require setono/sylius-loyalty-plugin +``` + +Register the plugin **before** `SyliusGridBundle` in `config/bundles.php`: + +```php + ['all' => true], + Sylius\Bundle\GridBundle\SyliusGridBundle::class => ['all' => true], + // ... +]; +``` + +Import the routes in `config/routes/setono_sylius_loyalty.yaml`: + +```yaml +setono_sylius_loyalty_admin: + resource: '@SetonoSyliusLoyaltyPlugin/Resources/config/routes/admin.yaml' + prefix: /admin + +setono_sylius_loyalty_shop: + resource: '@SetonoSyliusLoyaltyPlugin/Resources/config/routes/shop.yaml' + prefix: /{_locale} + requirements: + _locale: ^[a-z]{2}(?:_[A-Z]{2})?$ +``` + +If your shop does not use locale-prefixed URLs, import the shop routes without the `prefix`/`requirements` block. + +### Extend your Order entity + +The plugin persists the customer's redemption request on the order. Add the trait and interface to your `Order` entity: + +```php + + manual_adjustment_reasons: [goodwill, correction, promotion, other] + + # Your own earning trigger event classes (see the cookbook) + triggers: [] + + # On customer deletion, keep de-identified ledger rows linked to an opaque + # token instead of deleting everything (accounting continuity) + retain_anonymized_ledger: false + + referral: + # The query parameter recognized as a referral code on any shop URL + query_parameter: ref + # Opt-in registration-IP fraud check (stores a salted hash, purged after 90 days) + registration_ip_check: false + ip_hash_salt: '%kernel.secret%' + # Rewarded referrals per referrer per 30 days before the cap check flags + reward_cap: 10 + + expression_editor: + # The admin expression editor loads CodeMirror as version-pinned ESM + # imports from this base URL. Point it at a self-hosted copy for + # intranet or strict-CSP environments. + cdn_base_url: 'https://esm.sh' + + resources: + # every model/repository/factory/form is swappable here +``` + +Per-channel program settings (conversion rate, minimum redemption, cap, expiry, clawback policy, award moment, +earning basis, rounding) live in the admin under **Marketing → Loyalty → Program**. + +> **Note:** all point amounts convert against the **channel's base currency**; multi-currency channels convert at +> display time. Expiring stored value may be subject to consumer-protection law in some jurisdictions — check before +> enabling `points expire after (days)`. + +## Cron jobs + +| Command | Suggested schedule | Purpose | +|---|---|---| +| `setono:sylius-loyalty:expire-points` | daily, off-peak | writes `expire` debits for lots past their expiry | +| `setono:sylius-loyalty:trigger-birthdays` | daily | fires the `customer_birthday` trigger (idempotent per year) | +| `setono:sylius-loyalty:prune-dry-run-results --days=30` | daily | prunes the dry-run audit list | +| `setono:sylius-loyalty:evaluate-tiers` | nightly | tier reconciliation: downgrades after the grace period | +| `setono:sylius-loyalty:calculate-liability` | nightly, off-peak | snapshots the outstanding-liability dashboard widget | +| `setono:sylius-loyalty:expire-referrals` | daily | expires stale pending referrals, purges old IP hashes | +| `sylius:cancel-unpaid-orders` (Sylius core) | hourly | frees redemptions held by abandoned unpaid orders | + +Operational commands (run on demand): `verify-ledger`, `recalculate-balances` (report-only by default), +`inspect-account `, `export-customer-data ` (GDPR JSON). + +## Extension points + +Implementing the interface is the entire integration — every extension interface is autoconfigured. See the +[cookbook](docs/cookbook) for complete recipes: + +- [Custom condition type](docs/cookbook/custom-condition-type.md) +- [Custom amount type](docs/cookbook/custom-amount-type.md) +- [Custom earning trigger](docs/cookbook/custom-trigger.md) +- [Custom expression function](docs/cookbook/custom-expression-function.md) +- [Custom transaction type](docs/cookbook/custom-transaction-type.md) +- [Intercepting points with pre-events](docs/cookbook/intercepting-pre-events.md) +- [Rendering the balance anywhere](docs/cookbook/rendering-the-balance.md) +- [Clawback on partial refunds](docs/cookbook/partial-refund-clawback.md) +- [Custom tier qualification basis](docs/cookbook/custom-tier-qualification-basis.md) +- [Custom referral fraud check](docs/cookbook/custom-referral-fraud-check.md) + +## Notes for specific setups + +- **Birthday trigger**: Sylius collects birthdays only if your registration/profile forms do — the trigger is a silent + no-op for customers without one. +- **Strict CSP / no CDN**: self-host the editor's ESM dependencies and point `expression_editor.cdn_base_url` at them — a relative base makes the editor load plain `.js` shims (`codemirror.js`, `codemirror-language.js`, `codemirror-autocomplete.js`, `codemirror-lint.js`) instead of the CDN's versioned URLs. The plugin's test app ships such a snapshot under `tests/Application/public/vendor/codemirror/` you can copy. +- **Async awarding**: `AwardOrderPoints` and `ClaimPastOrderPoints` are dispatched through Messenger (sync by + default). Route them to any transport — awarding is idempotent, so redeliveries are safe. + +## Contributing + +```bash +composer install +(cd tests/Application && yarn install && yarn build && bin/console doctrine:database:create doctrine:schema:create -e test) +composer analyse # PHPStan, level max +composer check-style +vendor/bin/phpunit --testsuite unit +vendor/bin/phpunit --testsuite functional # needs the test-app database +(cd e2e && npx playwright test) # needs seeded fixtures: default + loyalty suites +``` [ico-version]: https://poser.pugx.org/setono/sylius-loyalty-plugin/v/stable [ico-license]: https://poser.pugx.org/setono/sylius-loyalty-plugin/license diff --git a/cart-redemption-widget.png b/cart-redemption-widget.png new file mode 100644 index 0000000..f85f1f3 Binary files /dev/null and b/cart-redemption-widget.png differ diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 01f48c9..10ffbbc 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -1,7 +1,32 @@ disableReportingUnmatchedIgnores() ->addPathToExclude(__DIR__ . '/tests') + // The Sylius monorepo (sylius/sylius) is installed for development and `replace`s the split + // packages this plugin actually depends on (sylius/core-bundle and its components), so class + // usages resolve to the monorepo package. + ->ignoreErrorsOnPackage('sylius/sylius', [ErrorType::SHADOW_DEPENDENCY]) + ->ignoreErrorsOnPackage('sylius/core-bundle', [ErrorType::UNUSED_DEPENDENCY]) + // The split components the plugin directly uses: under the monorepo dev install their + // classes resolve to sylius/sylius, so they look unused locally while being real + // dependencies of a split-package install. + ->ignoreErrorsOnPackages([ + 'sylius/channel', + 'sylius/channel-bundle', + 'sylius/core', + 'sylius/customer', + 'sylius/order', + 'sylius/promotion', + 'sylius/review', + 'sylius/ui-bundle', + 'sylius/user', + ], [ErrorType::UNUSED_DEPENDENCY]) + // The bundle base class comes from http-kernel; it is only referenced transitively but is a + // real runtime requirement of any Symfony bundle. + ->ignoreErrorsOnPackage('symfony/http-kernel', [ErrorType::UNUSED_DEPENDENCY]) ; diff --git a/composer.json b/composer.json index d01a1e4..3bcbc4a 100644 --- a/composer.json +++ b/composer.json @@ -1,23 +1,65 @@ { "name": "setono/sylius-loyalty-plugin", - "description": "Setono example plugin for Sylius.", + "description": "Loyalty plugin for Sylius: points earning and redemption backed by an append-only ledger, tiers, and referrals.", "license": "MIT", "type": "sylius-plugin", "keywords": [ "sylius", - "sylius-plugin" + "sylius-plugin", + "loyalty", + "loyalty-program", + "points", + "tiers", + "referrals", + "setono" ], "require": { "php": ">=8.1", - "sylius/core-bundle": "^1.0", + "ext-mbstring": "*", + "doctrine/collections": "^1.8 || ^2.0", + "doctrine/dbal": "^3.0", + "doctrine/orm": "^2.14", + "doctrine/persistence": "^2.0 || ^3.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "setono/composite-compiler-pass": "^1.2", + "setono/doctrine-orm-trait": "^1.4", + "sylius/channel": "^1.14", + "sylius/channel-bundle": "^1.14", + "sylius/core": "^1.14", + "sylius/core-bundle": "^1.14", + "sylius/customer": "^1.14", + "sylius/order": "^1.14", + "sylius/promotion": "^1.14", + "sylius/resource-bundle": "^1.11", + "sylius/review": "^1.14", + "sylius/ui-bundle": "^1.14", + "sylius/user": "^1.14", "symfony/config": "^6.4", + "symfony/console": "^6.4", "symfony/dependency-injection": "^6.4", - "symfony/http-kernel": "^6.4" + "symfony/event-dispatcher": "^6.4", + "symfony/expression-language": "^6.4", + "symfony/form": "^6.4", + "symfony/http-foundation": "^6.4", + "symfony/http-kernel": "^6.4", + "symfony/messenger": "^6.4", + "symfony/options-resolver": "^6.4", + "symfony/routing": "^6.4", + "symfony/security-bundle": "^6.4", + "symfony/security-csrf": "^6.4", + "symfony/translation-contracts": "^2.5 || ^3.0", + "symfony/validator": "^6.4", + "symfony/workflow": "^6.4", + "twig/twig": "^3.10", + "webmozart/assert": "^1.11" }, "require-dev": { "api-platform/core": "^2.7.16", "babdev/pagerfanta-bundle": "^3.8", "behat/behat": "^3.14", + "dama/doctrine-test-bundle": "^7.2", + "doctrine/data-fixtures": "^1.7", "doctrine/doctrine-bundle": "^2.11", "jms/serializer-bundle": "^4.2", "lexik/jwt-authentication-bundle": "^2.17", @@ -29,6 +71,7 @@ "symfony/intl": "^6.4", "symfony/property-info": "^6.4", "symfony/serializer": "^6.4", + "symfony/translation": "^6.4.7", "symfony/web-profiler-bundle": "^6.4", "symfony/webpack-encore-bundle": "^1.17.2", "willdurand/negotiation": "^3.1" diff --git a/docs/cookbook/custom-amount-type.md b/docs/cookbook/custom-amount-type.md new file mode 100644 index 0000000..0748140 --- /dev/null +++ b/docs/cookbook/custom-amount-type.md @@ -0,0 +1,100 @@ +# Adding a custom amount type + +An amount type decides how many points a matched earning rule awards. It is a service +implementing `Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculatorInterface`: + +```php +interface AmountCalculatorInterface +{ + public function getType(): string; + + /** A translation key for the rule form's amount type select. */ + public function getLabel(): string; + + /** @return class-string|null The form type rendering this amount's configuration, or null if it has none. */ + public function getConfigurationFormType(): ?string; + + /** + * Returns the (unrounded) points for the claimed basis. The program's rounding is applied + * once on the final total, not per rule. + * + * @param array $configuration + */ + public function calculate(array $configuration, AmountCalculationInput $input): float; +} +``` + +`calculate()` receives an `Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculationInput` +describing the basis the rule claimed during evaluation: + +- `$input->basisAmount` — the eligible amount in minor units of the channel base currency +- `$input->units` — the number of matching units (order-scoped rules always claim with + `units = 1`; item-scoped rules claim the matching items' quantities) +- `$input->context` — the full `EarningContext` (channel, customer, order, trigger context, ...) + +Return a `float` and do not round — the program's rounding mode (floor/round/ceil) is applied +once on the rule total. + +## Example: points per amount, capped + +Like the built-in `per_amount` calculator ("X points per Y minor units of claimed basis") but +with a per-award ceiling: + +```php +basisAmount / $perAmount * (float) $points, (float) $max); + } +} +``` + +Implementing the interface is the whole integration: the plugin registers +`AmountCalculatorInterface` for autoconfiguration, so the service is tagged +`setono_sylius_loyalty.earning_amount` automatically and the type appears in the amount type +select of the admin earning rule form (add the translation for your label key). The +configuration is entered in the rule form and passed to `calculate()` as an array — validate it +defensively and return `0.0` on invalid shapes. + +Only if your project has `autoconfigure: false` do you need to tag manually: + +```yaml +# config/services.yaml +App\Loyalty\Amount\CappedPerAmountCalculator: + tags: ['setono_sylius_loyalty.earning_amount'] +``` diff --git a/docs/cookbook/custom-condition-type.md b/docs/cookbook/custom-condition-type.md new file mode 100644 index 0000000..ecc2eec --- /dev/null +++ b/docs/cookbook/custom-condition-type.md @@ -0,0 +1,86 @@ +# Adding a custom condition type + +Earning rules are made of conditions. A condition type is a service implementing +`Setono\SyliusLoyaltyPlugin\EarningRule\Checker\ConditionCheckerInterface`: + +```php +interface ConditionCheckerInterface +{ + public function getType(): string; + + /** A translation key for the rule form's condition type select. */ + public function getLabel(): string; + + /** @return class-string|null The form type rendering this condition's configuration, or null if it has none. */ + public function getConfigurationFormType(): ?string; + + /** @param array $configuration */ + public function check(array $configuration, EarningContext $context): bool; +} +``` + +`check()` receives the condition's stored configuration and an +`Setono\SyliusLoyaltyPlugin\EarningRule\EarningContext` exposing everything a rule may look at: +`$context->channel`, `$context->customer`, `$context->account`, `$context->order` (null for +action-trigger earning), `$context->itemAmounts` (order item id => eligible amount in minor +units), `$context->context` (typed trigger context variables), plus `$context->getNow()` and +`$context->getBasis()`. Use `getNow()` instead of `new \DateTimeImmutable()` so the admin rule +tester can preview scheduled rules. + +## Example: newsletter subscribers only + +```php +customer?->isSubscribedToNewsletter() ?? false; + } +} +``` + +That is the whole integration: the plugin registers `ConditionCheckerInterface` for +autoconfiguration, so any service implementing it is tagged +`setono_sylius_loyalty.earning_condition` automatically. Add the +`app.form.earning_rule.condition.newsletter_subscriber` translation and the type appears in the +condition type select of the admin earning rule form. + +If your condition needs configuration (like the built-in `order_total_at_least` checker, which +reads `$configuration['amount']`), the admin enters it in the rule form's JSON configuration +field and it arrives in `check()` as an array — validate the shape defensively and return +`false` on garbage, never throw. `getConfigurationFormType()` is the hook for rendering a +dedicated configuration form type instead of the JSON field. + +Only if your project has `autoconfigure: false` do you need to tag manually: + +```yaml +# config/services.yaml +App\Loyalty\Checker\NewsletterSubscriberConditionChecker: + tags: ['setono_sylius_loyalty.earning_condition'] +``` diff --git a/docs/cookbook/custom-expression-function.md b/docs/cookbook/custom-expression-function.md new file mode 100644 index 0000000..e7642c1 --- /dev/null +++ b/docs/cookbook/custom-expression-function.md @@ -0,0 +1,91 @@ +# Adding a custom expression function + +Expression conditions run in a sandboxed expression language with domain functions like +`orders_count()` and `taxon_total('...')`. A custom function is a service implementing +`Setono\SyliusLoyaltyPlugin\Expression\Function\ExpressionFunctionInterface`: + +```php +interface ExpressionFunctionInterface +{ + public function getName(): string; + + /** E.g. "taxon_total(taxonCode: string): int". */ + public function getSignature(): string; + + /** A translation key describing the function. */ + public function getDescription(): string; + + public function evaluate(EarningContext $context, mixed ...$arguments): mixed; +} +``` + +## Example: days since registration + +```php +customer?->getCreatedAt(); + if (null === $createdAt) { + return 0; + } + + // Use the context clock so the admin rule tester can preview scheduled rules + return $createdAt->diff($context->getNow())->days; + } +} +``` + +Implementing the interface is the whole integration: the plugin registers +`ExpressionFunctionInterface` for autoconfiguration, so the service is tagged +`setono_sylius_loyalty.expression_function` automatically. The metadata (`getName()`, +`getSignature()`, `getDescription()`) feeds the admin expression editor's **autocompletion** +and the generated **reference panel**, so your function shows up in both without further work — +add the translation for the description key. + +An expression like this then validates and evaluates: + +``` +days_since_registration() >= 365 and orders_count() >= 3 +``` + +Guidelines: + +- Read state from the `EarningContext` (`channel`, `customer`, `order`, trigger `context`, ...) + and return early with a neutral value when a part is missing — action-trigger earning has no + order, for example. +- Use `$context->getNow()` for time, never `new \DateTimeImmutable()`. +- Validate `$arguments` defensively; expressions are admin-authored. + +Only if your project has `autoconfigure: false` do you need to tag manually: + +```yaml +# config/services.yaml +App\Loyalty\Expression\DaysSinceRegistrationFunction: + tags: ['setono_sylius_loyalty.expression_function'] +``` diff --git a/docs/cookbook/custom-referral-fraud-check.md b/docs/cookbook/custom-referral-fraud-check.md new file mode 100644 index 0000000..73e6238 --- /dev/null +++ b/docs/cookbook/custom-referral-fraud-check.md @@ -0,0 +1,86 @@ +# Adding a custom referral fraud check + +Referral fraud checks run at qualification, against the referee's qualifying order. A check is a +service implementing `Setono\SyliusLoyaltyPlugin\Referral\FraudCheck\ReferralFraudCheckInterface`: + +```php +interface ReferralFraudCheckInterface +{ + public function check(ReferralInterface $referral, OrderInterface $order): ?FraudFlag; +} +``` + +Return `null` to pass. Return a `FraudFlag` to reject: **any** flag from **any** check rejects +the referral. All flags are stored on the referral (`ReferralInterface::getFraudFlags()`), so +admins can see exactly why a referral was rejected — and override the rejection if the flag +turns out to be a false positive. + +`FraudFlag` carries a machine-readable check code and a human-readable detail: + +```php +new FraudFlag('app_disposable_email', 'The referee registered with the disposable email domain "mailinator.com"') +``` + +Prefix your check codes (e.g. `app_`) so they cannot collide with the built-in ones. + +## Example: reject disposable email domains + +```php +getRefereeCustomer()?->getEmailCanonical(); + if (null === $email || false === ($atPosition = strrpos($email, '@'))) { + return null; + } + + $domain = strtolower(substr($email, $atPosition + 1)); + if (in_array($domain, self::DISPOSABLE_DOMAINS, true)) { + return new FraudFlag('app_disposable_email', sprintf('The referee registered with the disposable email domain "%s"', $domain)); + } + + return null; + } +} +``` + +That is the whole integration: the plugin registers `ReferralFraudCheckInterface` for +autoconfiguration, so any service implementing it is tagged +`setono_sylius_loyalty.referral_fraud_check` automatically and runs alongside the shipped +checks on every qualification. + +Only if your project has `autoconfigure: false` do you need to tag manually: + +```yaml +# config/services.yaml +App\Loyalty\FraudCheck\DisposableEmailDomainCheck: + tags: ['setono_sylius_loyalty.referral_fraud_check'] +``` + +## Shipped checks + +The plugin ships four checks: **self-referral** (same customer, identically normalized emails, +or a shared address between referrer and referee), **registration IP** (opt-in; flags a referee +registering from the same IP as another referee of the same referrer, using salted hashes purged +after 90 days), **account age** (the referee account existed before the referral was captured), +and **reward cap** (the referrer exceeded the rewarded-referrals cap for the trailing 30 days, +default 10). diff --git a/docs/cookbook/custom-tier-qualification-basis.md b/docs/cookbook/custom-tier-qualification-basis.md new file mode 100644 index 0000000..2b37f99 --- /dev/null +++ b/docs/cookbook/custom-tier-qualification-basis.md @@ -0,0 +1,82 @@ +# Custom tier qualification basis + +Tiers qualify on a metric computed by a *qualification basis*. The plugin ships `points_earned`, +`amount_spent`, and `orders_count`; adding your own makes it appear in the tier form's basis +select automatically. + +Implement `TierQualificationBasisInterface`: + +```php +entityManager->createQueryBuilder() + ->select('COUNT(r.id)') + ->from(\Sylius\Component\Core\Model\ProductReview::class, 'r') + ->andWhere('r.author = :customer') + ->andWhere('r.status = :accepted') + ->setParameter('customer', $account->getCustomer()) + ->setParameter('accepted', 'accepted') + ; + + // A null window means lifetime. A custom basis may interpret or ignore the window — + // that is the escape hatch for exotic windows. + if (null !== $window) { + $qb->andWhere('r.createdAt >= :start')->setParameter('start', $window->start) + ->andWhere('r.createdAt <= :end')->setParameter('end', $window->end); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} +``` + +With autoconfiguration enabled (the default), implementing the interface is the whole +integration — the class is tagged `setono_sylius_loyalty.tier_qualification_basis` +automatically. Without autoconfigure, tag it yourself: + +```yaml +services: + App\Loyalty\ReviewsWrittenBasis: + tags: ['setono_sylius_loyalty.tier_qualification_basis'] +``` + +Notes: + +- `calculate()` runs inside the ledger transaction on inline upgrades, so keep it a cheap + aggregate query — never a replay. +- The window resolved from the program (`calendar_year` | `rolling_12_months` | `lifetime`) + is handed to you; use inclusive bounds (`>= start`, `<= end`) so activity from the very + second of the evaluation counts. +- The unit label shows up as the threshold field's hint in the tier form. diff --git a/docs/cookbook/custom-transaction-type.md b/docs/cookbook/custom-transaction-type.md new file mode 100644 index 0000000..bd613f4 --- /dev/null +++ b/docs/cookbook/custom-transaction-type.md @@ -0,0 +1,112 @@ +# Adding a custom transaction type + +The ledger is a Doctrine single-table-inheritance hierarchy rooted at +`Setono\SyliusLoyaltyPlugin\Model\LoyaltyTransaction` (table +`setono_sylius_loyalty__transaction`, discriminator column `type`, length 64). You can add your +own transaction types — e.g. an imported balance from a legacy program — without touching the +plugin's mapping. + +## 1. The entity + +Extend `Setono\SyliusLoyaltyPlugin\Model\CreditLoyaltyTransaction` (credits carry an optional +`expiresAt` and act as lots) or `Setono\SyliusLoyaltyPlugin\Model\DebitLoyaltyTransaction`: + +```php +legacyReference; + } + + public function setLegacyReference(?string $legacyReference): void + { + $this->legacyReference = $legacyReference; + } +} +``` + +## 2. The Doctrine mapping (host-side) + +The plugin maps the parent; you map your subclass. Because the hierarchy is single-table, every +extra column must be nullable: + +```xml + + + + + + + + +``` + +(Attribute mapping works just as well if that is what your project uses.) Generate a migration +with `doctrine:migrations:diff` — the plugin ships no migrations. + +## 3. Register the class as a resource + +```yaml +# config/packages/sylius_resource.yaml +sylius_resource: + resources: + app.legacy_import_loyalty_transaction: + classes: + model: App\Entity\Loyalty\LegacyImportLoyaltyTransaction +``` + +The plugin scans the registered resources at metadata load time and adds every model +extending the transaction root to the discriminator map under its own +`getDiscriminator()` value, so `legacy_import` becomes a valid `type` value alongside the +built-ins (`earn_order`, `earn_action`, `earn_referral`, `redeem`, `redeem_rollback`, +`expire`, `clawback`, `manual_credit`, `manual_debit`). + +## All writes still go through the ledger + +The ledger is **append-only** and `Setono\SyliusLoyaltyPlugin\Ledger\LoyaltyLedgerInterface` is +its single write path: every write locks the account row (pessimistic write lock inside a +transaction), appends the entry, and updates the cached `balance`/`lifetimeEarned` in the same +transaction. Never `persist()` a transaction row directly, and never update or delete one — +corrections are new compensating entries. + +To actually write your custom type, follow the same pattern the default ledger uses — typically +by decorating `LoyaltyLedgerInterface` (its service id is the interface FQCN, so re-alias or +use Symfony service decoration) with a method for your type: + +```php +$this->entityManager->wrapInTransaction(function () use ($accountId, $points): void { + $account = $this->entityManager + ->getRepository($this->accountClass) + ->find($accountId, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE); + + $transaction = new LegacyImportLoyaltyTransaction(); + $transaction->setAccount($account); + $transaction->setPoints($points); // credits positive, debits negative + $this->entityManager->persist($transaction); + + $account->setBalance($account->getBalance() + $points); + $account->setLifetimeEarned($account->getLifetimeEarned() + $points); +}); +``` + +Custom rows participate in replay like any other: credits with an `expiresAt` are lots consumed +FIFO, and `setono:sylius-loyalty:verify-ledger` checks them against the same invariants. diff --git a/docs/cookbook/custom-trigger.md b/docs/cookbook/custom-trigger.md new file mode 100644 index 0000000..42bc18d --- /dev/null +++ b/docs/cookbook/custom-trigger.md @@ -0,0 +1,87 @@ +# Adding a custom earning trigger + +A trigger *is* an event class: extend +`Setono\SyliusLoyaltyPlugin\Event\Trigger\EarningTriggerEvent`, register the class under the +`setono_sylius_loyalty.triggers` config node, and fire it with a plain event dispatch. The +plugin evaluates the trigger's earning rules and writes the award — you never touch the ledger. + +## 1. The event class + +```php +eventDispatcher->dispatch(new SocialShareTriggerEvent($customer, 'instagram')); +``` + +## Source identifier semantics + +`sourceIdentifier` deduplicates awards per account at the database level (a unique constraint — +concurrent dispatches and message redeliveries are no-ops by construction): + +- **Default** (pass `null` / omit): the source identifier is the trigger code, which means + **once per account, ever**. Right for one-shot actions like "completed profile". +- **Repeatable triggers pass their own**: the built-ins use `review:` and + `birthday:`; the example above uses `social_share:`. A dispatch with an + already-awarded identifier is a silent no-op, so re-dispatching is always safe. + +## Channel + +Pass the channel when you know it. When `null`, the plugin resolves it via the +`TriggerChannelResolver` chain (re-alias +`Setono\SyliusLoyaltyPlugin\Resolver\TriggerChannelResolverInterface` to customize); an +unresolvable channel makes the dispatch a logged no-op. diff --git a/docs/cookbook/intercepting-pre-events.md b/docs/cookbook/intercepting-pre-events.md new file mode 100644 index 0000000..87fde31 --- /dev/null +++ b/docs/cookbook/intercepting-pre-events.md @@ -0,0 +1,89 @@ +# Intercepting ledger writes with pre-events + +Right before writing a ledger entry, the ledger dispatches a pre-event — inside the account +lock, in the same database transaction. Listeners can adjust or cancel the pending write. The +events are plain objects dispatched by class name (`Setono\SyliusLoyaltyPlugin\Event\...`): + +| Event | Adjustable | `cancel()` means | +|---|---|---| +| `AwardingPoints` | `setPoints()`, `setExpiresAt()` | No credit is written | +| `RedeemingPoints` | nothing (the debit must equal the applied points) | Checkout completion aborts with a validation error | +| `ExpiringPoints` | nothing (points equal the lot's replay-derived remaining) | The lot is deferred and re-selected on the next expiry run | +| `ClawingBackPoints` | `setPoints()` | No clawback debit is written | + +`AwardingPoints` covers both earning paths: `getOrder()` is set for order earning (and +`getSourceIdentifier()` is null), while action-trigger earning sets `getSourceIdentifier()` +(and `getOrder()` is null). All four events expose `getAccount()`. + +## Example: double points for staff + +```php +getAccount()->getCustomer()?->getEmail(); + if (null === $email || !str_ends_with($email, '@my-company.com')) { + return; + } + + $event->setPoints($event->getPoints() * 2); + } +} +``` + +No configuration needed: `#[AsEventListener]` is autoconfigured by Symfony, and the events are +dispatched with the standard event dispatcher (an `EventSubscriberInterface` subscribing to +`AwardingPoints::class` works the same way). + +## Example: a grace period before expiration + +Cancelling `ExpiringPoints` writes nothing and leaves the lot open — it is simply re-selected +on the next `setono:sylius-loyalty:expire-points` run, which makes it the hook for +project-level grace logic: + +```php +getLot()->getExpiresAt(); + + // Give every lot 14 extra days beyond its nominal expiry + if (null !== $expiresAt && $expiresAt->modify('+14 days') > new \DateTimeImmutable()) { + $event->cancel(); + } + } +} +``` + +Notes: + +- Setting `AwardingPoints` points to `0` (or below) has the same effect as `cancel()`. +- Cancelling `RedeemingPoints` is a hard stop: the ledger throws a `LedgerConflictException` + and the checkout `complete` transition is aborted — use it for last-second fraud checks, not + for soft adjustments. +- Listeners run inside the ledger transaction: keep them fast and side-effect free. For + reacting *after* a write committed, use the post-commit events instead (`PointsEarned`, + `PointsRedeemed`, `PointsExpired`, `PointsClawedBack`, `RedemptionRolledBack`, + `ManualAdjustment`). diff --git a/docs/cookbook/partial-refund-clawback.md b/docs/cookbook/partial-refund-clawback.md new file mode 100644 index 0000000..96cd1dc --- /dev/null +++ b/docs/cookbook/partial-refund-clawback.md @@ -0,0 +1,81 @@ +# Clawing back points on a partial refund + +Full clawback ships built in: when an order that earned points is cancelled, or its payment +goes through the `refund` transition, the plugin debits the earned points automatically. +**Partial** refunds are project-specific (Sylius core has no partial-refund flow), so the +ledger exposes the write as a public extension point: + +```php +// Setono\SyliusLoyaltyPlugin\Ledger\LoyaltyLedgerInterface +public function clawback(OrderInterface $order, int $points): ?ClawbackLoyaltyTransactionInterface; +``` + +`$points` is a positive magnitude (the ledger applies the sign). The earn credit is looked up +via the order; if the order never earned points, the call is a no-op returning `null`. + +## Example: proportional clawback from a refund event + +A listener on your refund mechanism's event (Sylius Refund Plugin, a PSP webhook, a custom +admin action — anything that knows the order and the refunded amount): + +```php +getOrder(); + + $earn = $this->transactionRepository->findEarnOrderTransaction($order); + if (null === $earn || $order->getTotal() <= 0) { + return; // the order never earned points + } + + // Claw back the earned points proportionally to the refunded share of the order + $points = (int) round($earn->getPoints() * $event->getRefundedAmount() / $order->getTotal()); + if ($points <= 0) { + return; + } + + $this->ledger->clawback($order, $points); + } +} +``` + +Both `LoyaltyLedgerInterface` and `LoyaltyTransactionRepositoryInterface` are aliased to their +interface ids, so autowiring works. + +## What the ledger does with it + +- **Pre-event** — `Setono\SyliusLoyaltyPlugin\Event\ClawingBackPoints` is dispatched first; + listeners may adjust the points or cancel the write (then `clawback()` returns `null`). +- **Clawback policy (clamp)** — with the program's default `clamp_to_zero` policy, the debit is + reduced at write time to `min(points, balance)` so the balance lands at exactly zero and + never goes negative — the ledger entry records what was actually debited. With the + `allow_negative` policy the full amount is debited and the balance may go negative. +- **Idempotency** — a database unique constraint guarantees each earn credit is clawed back + **at most once**. Repeating the call for the same order (event redelivery, concurrent + processes) is a silent no-op returning `null`. + +That last point has a design consequence: an order supports **one** clawback entry, ever. If +your flow can refund the same order in several steps, aggregate the refunded amounts and call +`clawback()` once with the final proportional total — a second call will not write anything. +For the same reason, once your partial clawback is written, the built-in full clawback on a +later cancellation/refund of that order becomes a no-op (and vice versa). diff --git a/docs/cookbook/rendering-the-balance.md b/docs/cookbook/rendering-the-balance.md new file mode 100644 index 0000000..e823e8e --- /dev/null +++ b/docs/cookbook/rendering-the-balance.md @@ -0,0 +1,82 @@ +# Rendering the balance in your templates + +The plugin ships a shop account page and cart/checkout blocks, but the Twig functions behind +them are public, so you can render loyalty data anywhere (header badge, dashboard, emails): + +| Function | Returns | +|---|---| +| `setono_sylius_loyalty_accounts(customer)` | The customer's loyalty accounts (one per channel) | +| `setono_sylius_loyalty_latest_transactions(account, limit = 25)` | The account's latest ledger entries, newest first | +| `setono_sylius_loyalty_transaction_type(transaction)` | The entry's discriminator value (e.g. `"earn_order"`, `"redeem"`) | +| `setono_sylius_loyalty_cart_redemption(cart)` | A `CartRedemptionView` for the cart's channel/customer (balance, presets, requested/applied points), or `null` | + +## Example: a header points badge with recent activity + +```twig +{# templates/bundles/SyliusShopBundle/_loyalty_badge.html.twig #} +{% if app.user is not null and app.user.customer is not null %} + {% set accounts = setono_sylius_loyalty_accounts(app.user.customer)|filter( + account => account.channel is same as(sylius.channel) + ) %} + + {% for account in accounts %} + + + {{ account.balance }} {{ 'setono_sylius_loyalty.ui.points'|trans }} + + + + {% endfor %} +{% endif %} +``` + +Accounts are per (customer, channel) — `setono_sylius_loyalty_accounts()` returns all of them, +so filter by the current channel as above. `account.balance` is the cached balance maintained +by the ledger; it is always safe to render directly. + +## The account in PHP + +Inject `Setono\SyliusLoyaltyPlugin\Provider\LoyaltyAccountProviderInterface` (the interface is +the service alias, so autowiring works). `getByCustomerAndChannel()` creates the account lazily +on first access, so it never returns null: + +```php +accountProvider + ->getByCustomerAndChannel($customer, $this->channelContext->getChannel()) + ->getBalance(); + } +} +``` + +The account also exposes `getLifetimeEarned()` and `isEnabled()` — check the latter before +advertising earning or redemption to a customer whose account an admin has disabled. diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..512cd84 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +.playwright-cache/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..e3e34f8 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "setono-sylius-loyalty-plugin-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "setono-sylius-loyalty-plugin-e2e", + "devDependencies": { + "@playwright/test": "^1.53.0", + "@types/node": "^22.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..752dcaf --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "setono-sylius-loyalty-plugin-e2e", + "private": true, + "description": "Playwright end-to-end test suite for the Setono Sylius Loyalty Plugin, run against the Sylius test application in tests/Application.", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.53.0", + "@types/node": "^22.0.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..ad0022b --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * The suite runs against the Sylius test application in ../tests/Application. + * + * Prerequisites (handled by CI, see .github/workflows/build.yaml): + * - composer dependencies installed + * - test app assets built (yarn install && yarn build in tests/Application) + * - database created with schema and fixtures loaded (APP_ENV=test) + * + * The database is seeded once and shared by all specs, so the suite runs with a + * single worker for determinism. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + timeout: 30_000, + use: { + baseURL: 'http://127.0.0.1:8082', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + locale: 'en_US', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // variables_order must include E so the APP_ENV env var survives into $_SERVER, where + // Symfony's Dotenv looks for it — without it the server silently runs the dev env. + // Port 8082 avoids colliding with a locally running dev server. + command: 'php -d variables_order=EGPCS -S 127.0.0.1:8082 -t ../tests/Application/public', + url: 'http://127.0.0.1:8082', + reuseExistingServer: !process.env.CI, + env: { + APP_ENV: 'test', + APP_DEBUG: '0', + }, + }, +}); diff --git a/e2e/tests/admin-loyalty.spec.ts b/e2e/tests/admin-loyalty.spec.ts new file mode 100644 index 0000000..6b99c2d --- /dev/null +++ b/e2e/tests/admin-loyalty.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test'; +import { loginAdmin } from './helpers'; + +test.describe('admin loyalty', () => { + test.beforeEach(async ({ page }) => { + await loginAdmin(page); + }); + + test('the dashboard shows stats and navigation cards', async ({ page }) => { + await page.goto('/admin/loyalty'); + + await expect(page.locator('[data-test-stat-accounts] .value')).not.toHaveText('0'); + await expect(page.locator('[data-test-card-accounts]')).toBeVisible(); + await expect(page.locator('[data-test-card-rules]')).toBeVisible(); + await expect(page.locator('[data-test-card-program]')).toBeVisible(); + await expect(page.locator('[data-test-card-tester]')).toBeVisible(); + }); + + test('the accounts grid links to the ledger inspector with healthy invariants', async ({ page }) => { + await page.goto('/admin/loyalty/accounts'); + + // history@example.com is read-only across the suite, so its ledger counts stay exact + // even on re-runs (the adjustment spec below appends rows to loyalty@example.com) + const row = page.locator('tr', { hasText: 'history@example.com' }); + await expect(row).toContainText('2150'); + await row.locator('a:has-text("Inspect")').click(); + + await expect(page.locator('[data-test-account-balance] .value')).toHaveText('2150'); + await expect(page.locator('[data-test-invariants-ok]')).toBeVisible(); + await expect(page.locator('[data-test-lots] tbody tr')).toHaveCount(2); + await expect(page.locator('[data-test-ledger] tbody tr')).toHaveCount(3); + }); + + test('a manual adjustment is written to the ledger with its reason and note', async ({ page }) => { + await page.goto('/admin/loyalty/accounts'); + await page.locator('tr', { hasText: 'loyalty@example.com' }).locator('a:has-text("Inspect")').click(); + + const form = page.locator('[data-test-adjustment-form]'); + await form.locator('input[name="points"]').fill('50'); + await form.locator('input[name="note"]').fill('e2e credit'); + await form.evaluate((f: HTMLFormElement) => f.submit()); + await expect(page.locator('[data-test-account-balance] .value')).toHaveText('2200'); + + // Net the balance back out so the suite stays re-runnable + const debitForm = page.locator('[data-test-adjustment-form]'); + await debitForm.locator('input[name="points"]').fill('-50'); + await debitForm.locator('input[name="note"]').fill('e2e debit'); + await debitForm.evaluate((f: HTMLFormElement) => f.submit()); + await expect(page.locator('[data-test-account-balance] .value')).toHaveText('2150'); + }); + + test('the earning rules grid lists the fixture rules', async ({ page }) => { + await page.goto('/admin/loyalty/earning-rules/'); + + await expect(page.locator('tr', { hasText: 'Base rate: 1 point per 1.00' })).toBeVisible(); + await expect(page.locator('tr', { hasText: 'Weekend double points (dry run)' })).toBeVisible(); + await expect(page.locator('tr', { hasText: 'Registration bonus' })).toBeVisible(); + }); + + test('the rule tester evaluates an order read-only with per-item claims', async ({ page }) => { + // Fixture order numbers are assigned sequentially, so the first one always exists + await page.goto('/admin/loyalty/rule-tester?order=000000001'); + + await expect(page.locator('[data-test-tester-award]')).toContainText('Final award'); + await expect(page.locator('[data-test-tester-rules] tbody tr', { hasText: 'Base rate: 1 point per 1.00' })).toBeVisible(); + await expect(page.locator('[data-test-tester-claims] tbody tr').first()).toBeVisible(); + }); + + // CodeMirror loads as ESM from the CDN; retry to absorb CDN flakiness + test.describe(() => { + test.describe.configure({ retries: 2 }); + + test('the expression editor mounts with completion and inline linting', async ({ page }) => { + test.slow(); + + await page.goto('/admin/loyalty/earning-rules/new'); + + // CodeMirror replaces the textarea; wait for the editable surface itself, not just + // the wrapper, so the following DOM-level inserts never race the mount + const editor = page.locator('.setono-sylius-loyalty-expression-editor').first(); + const content = page.locator('.setono-sylius-loyalty-expression-editor .cm-content').first(); + await expect(content).toBeVisible({ timeout: 45000 }); + + // Insert text through the browser's input pipeline (keyboard focus on CodeMirror's + // contenteditable is flaky headless; execCommand still triggers completion + linting). + // page.evaluate instead of locator.evaluate: the open completion tooltip re-renders + // the editor subtree, which can wedge the locator's element resolution mid-test. + const insert = (text: string) => + page.evaluate((value) => { + const content = document.querySelector('.setono-sylius-loyalty-expression-editor .cm-content'); + if (!(content instanceof HTMLElement)) { + throw new Error('editor content not found'); + } + content.focus(); + document.execCommand('insertText', false, value); + }, text); + + await insert('customer.'); + + // Nested, catalog-driven completion offers the customer view's members + const completion = page.locator('.cm-tooltip-autocomplete'); + await expect(completion).toBeVisible({ timeout: 10000 }); + await expect(completion).toContainText('email'); + + // An off-whitelist member produces a lint diagnostic from the server + await insert('password'); + await expect(editor.locator('.cm-lint-marker-error, .cm-lintRange-error').first()).toBeVisible({ timeout: 10000 }); + + // The reference panel renders from the same catalog and inserts examples + const reference = page.locator('.setono-sylius-loyalty-expression-reference').first(); + await expect(reference).toBeVisible(); + await reference.locator('summary').click(); + await expect(reference).toContainText('taxon_total'); + await reference.locator('a', { hasText: 'Weekend bonus' }).click(); + await expect(page.locator('.setono-sylius-loyalty-expression-editor .cm-content').first()).toContainText('day_of_week()'); + }); + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000..aba5e05 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,23 @@ +import { expect, Page } from '@playwright/test'; + +export async function loginShop(page: Page, email: string, password = 'sylius'): Promise { + await page.goto('/en_US/login'); + await page.locator('#_username').fill(email); + await page.locator('#_password').fill(password); + await Promise.all([ + page.waitForNavigation(), + page.locator('#_username').locator('xpath=ancestor::form').evaluate((form: HTMLFormElement) => form.submit()), + ]); + await expect(page.locator('a[href*="logout"]').first()).toBeVisible(); +} + +export async function loginAdmin(page: Page): Promise { + await page.goto('/admin/login'); + await page.locator('#_username').fill('sylius'); + await page.locator('#_password').fill('sylius'); + await Promise.all([ + page.waitForNavigation(), + page.locator('#_username').locator('xpath=ancestor::form').evaluate((form: HTMLFormElement) => form.submit()), + ]); + await expect(page).toHaveURL(/\/admin(\/|$)/); +} diff --git a/e2e/tests/referrals.spec.ts b/e2e/tests/referrals.spec.ts new file mode 100644 index 0000000..f4ae387 --- /dev/null +++ b/e2e/tests/referrals.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { loginAdmin, loginShop } from './helpers'; + +test.describe('referrals', () => { + test('the share block, landing URL, query parameter, and registration attribution', async ({ page, context }) => { + // The dashboard generates the code lazily and shows the share block + await loginShop(page, 'loyalty@example.com'); + await page.goto('/en_US/account/loyalty'); + + const code = (await page.locator('[data-test-referral-code]').textContent() ?? '').trim(); + expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{8}$/); + await expect(page.locator('[data-test-referral-url]')).toHaveValue(new RegExp(`/r/${code}$`)); + + // The landing URL sets the attribution cookie and redirects home + await page.goto('/en_US/logout'); + await context.clearCookies({ name: 'setono_sylius_loyalty_ref' }); + await page.goto(`/en_US/r/${code}`); + await expect(page).toHaveURL(/\/en_US\/$/); + expect((await context.cookies()).find(c => c.name === 'setono_sylius_loyalty_ref')?.value).toBe(code); + + // The query parameter works on any shop URL — last click wins + await context.clearCookies({ name: 'setono_sylius_loyalty_ref' }); + await page.goto(`/en_US/products/sport-basic-white-t-shirt?ref=${code}`); + expect((await context.cookies()).find(c => c.name === 'setono_sylius_loyalty_ref')?.value).toBe(code); + + // Registering with the cookie creates a pending referral, visible in the admin grid + const email = `referee-${Date.now()}@example.com`; + await page.goto('/en_US/register'); + await page.locator('#sylius_customer_registration_firstName').fill('Ref'); + await page.locator('#sylius_customer_registration_lastName').fill('Eree'); + await page.locator('#sylius_customer_registration_email').fill(email); + await page.locator('#sylius_customer_registration_user_plainPassword_first').fill('password123'); + await page.locator('#sylius_customer_registration_user_plainPassword_second').fill('password123'); + await Promise.all([ + page.waitForNavigation(), + page.locator('form[name="sylius_customer_registration"]').evaluate((f: HTMLFormElement) => f.submit()), + ]); + + await loginAdmin(page); + await page.goto('/admin/loyalty/referrals'); + const row = page.locator('tr', { hasText: email }); + await expect(row).toBeVisible(); + await expect(row).toContainText('pending'); + await expect(row).toContainText(code); + }); +}); diff --git a/e2e/tests/shop-loyalty.spec.ts b/e2e/tests/shop-loyalty.spec.ts new file mode 100644 index 0000000..94b4d97 --- /dev/null +++ b/e2e/tests/shop-loyalty.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; +import { loginShop } from './helpers'; + +test.describe('shop loyalty dashboard', () => { + test('shows the balance hero, expiring-soon callout, and running-balance history', async ({ page }) => { + // This account is dedicated to this spec, so its ledger stays exact + await loginShop(page, 'history@example.com'); + await page.goto('/en_US/account/loyalty'); + + // Fixture history: +2000 goodwill, +300 expiring, -150 correction = 2150 + await expect(page.locator('[data-test-loyalty-hero] .value')).toHaveText('2150'); + + // The 300-point lot expires within the 30-day horizon, minus the 150-point debit that + // replay attributes to it first (earliest expiry is consumed first) + await expect(page.locator('[data-test-loyalty-expiring]')).toContainText('150 points expire'); + + const rows = page.locator('[data-test-loyalty-history] tbody tr'); + await expect(rows).toHaveCount(3); + + // Newest first with bank-statement running balances + await expect(rows.nth(0)).toContainText('-150'); + await expect(rows.nth(0)).toContainText('2150'); + await expect(rows.nth(1)).toContainText('+300'); + await expect(rows.nth(1)).toContainText('2300'); + await expect(rows.nth(2)).toContainText('+2000'); + await expect(rows.nth(2)).toContainText('2000'); + + // Credit rows show their expiry date + await expect(rows.nth(1).locator('[data-test-expiry]')).toBeVisible(); + + // The account menu links here + await expect(page.locator('a:has-text("My loyalty")').first()).toBeVisible(); + }); +}); + +test.describe('cart redemption', () => { + test('applies a preset, uses max with clamping, and removes the redemption', async ({ page }) => { + await loginShop(page, 'loyalty@example.com'); + + // Fixture prices are random; a fixed quantity keeps the items total comfortably above + // the 500-point redemption minimum so presets always render + await page.goto('/en_US/products/sport-basic-white-t-shirt'); + await page.locator('input[name="sylius_add_to_cart[cartItem][quantity]"]').fill('25'); + await page.locator('button:has-text("Add to cart")').click(); + await expect(page).toHaveURL(/\/cart\//); + + const widget = page.locator('#setono-sylius-loyalty-redemption'); + await expect(widget).toBeVisible(); + await expect(widget.locator('[data-test-loyalty-balance]')).toHaveText('2150'); + + // Presets show the currency equivalent — the one place it appears + await expect(widget.locator('[data-test-loyalty-presets] button').first()).toContainText('('); + + // Use max records the whole balance; the applied amount is min(requested, cap) — and + // fixture prices are random, so either the full 2150 applies or the clamp notice + // explains the reduction from 2150 + await widget.locator('[data-test-loyalty-use-max]').click(); + const applied = page.locator('#setono-sylius-loyalty-redemption [data-test-loyalty-applied]'); + await expect(applied).toBeVisible(); + const clamped = page.locator('#setono-sylius-loyalty-redemption [data-test-loyalty-clamped]'); + if (await clamped.count() > 0) { + await expect(clamped).toContainText('2150'); + } else { + await expect(applied).toContainText('2150'); + } + + // Removing restores the pre-redemption state + await page.locator('#setono-sylius-loyalty-redemption [data-test-loyalty-remove]').click(); + await expect(page.locator('#setono-sylius-loyalty-redemption [data-test-loyalty-applied]')).toHaveCount(0); + }); + + test('is hidden for customers below the redemption minimum', async ({ page }) => { + await loginShop(page, 'fresh@example.com'); + + await page.goto('/en_US/products/sport-basic-white-t-shirt'); + await page.locator('button:has-text("Add to cart")').click(); + await expect(page).toHaveURL(/\/cart\//); + + await expect(page.locator('#setono-sylius-loyalty-redemption')).toHaveCount(0); + }); +}); diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000..c26daae --- /dev/null +++ b/e2e/tests/smoke.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +test.describe('smoke', () => { + test('shop homepage renders', async ({ page }) => { + await page.goto('/en_US/'); + + await expect(page.locator('body')).toContainText(/./); + expect(page.url()).toContain('/en_US/'); + }); + + test('admin login page renders and accepts the fixture credentials', async ({ page }) => { + await page.goto('/admin/login'); + + await page.getByLabel('Username').fill('sylius'); + await page.getByLabel('Password').fill('sylius'); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect(page).toHaveURL(/\/admin(\/|$)/); + await expect(page.locator('body')).toContainText('Dashboard'); + }); +}); diff --git a/e2e/tests/tiers.spec.ts b/e2e/tests/tiers.spec.ts new file mode 100644 index 0000000..6c9f1b8 --- /dev/null +++ b/e2e/tests/tiers.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { loginAdmin, loginShop } from './helpers'; + +test.describe('shop tier UI', () => { + test('shows the tier badge and progress toward the next tier', async ({ page }) => { + // history@example.com has 2300 lifetime points: Silver (1000), progressing to Gold (5000) + await loginShop(page, 'history@example.com'); + await page.goto('/en_US/account/loyalty'); + + await expect(page.locator('[data-test-tier-badge]')).toContainText('Silver'); + await expect(page.locator('[data-test-loyalty-tier]')).toContainText('25% points bonus'); + + const progress = page.locator('[data-test-tier-progress]'); + await expect(progress).toContainText('2300 / 5000'); + await expect(progress).toContainText('Gold'); + await expect(page.locator('[data-test-tier-celebration]')).toHaveCount(0); + }); +}); + +test.describe('earn hints', () => { + test('the product page shows the hint to anonymous visitors with a variant map', async ({ page }) => { + await page.goto('/en_US/products/sport-basic-white-t-shirt'); + + const hint = page.locator('[data-test-earn-hint]'); + await expect(hint).toBeVisible(); + await expect(hint).toContainText(/You will earn \d+ points/); + expect(await page.locator('#setono-sylius-loyalty-earn-hint-map span').count()).toBeGreaterThan(0); + }); + + test('the cart shows the order earn hint including the tier multiplier', async ({ page }) => { + await loginShop(page, 'loyalty@example.com'); + + await page.goto('/en_US/products/sport-basic-white-t-shirt'); + await page.locator('button:has-text("Add to cart")').click(); + await expect(page).toHaveURL(/\/cart\//); + + await expect(page.locator('[data-test-cart-earn-hint]')).toContainText(/earns ~\d+ points/); + }); +}); + +test.describe('admin tiers', () => { + test('the tiers grid lists the fixture tiers', async ({ page }) => { + await loginAdmin(page); + await page.goto('/admin/loyalty/tiers/'); + + await expect(page.locator('tr', { hasText: 'Silver' })).toBeVisible(); + await expect(page.locator('tr', { hasText: 'Gold' })).toBeVisible(); + }); + + test('the accounts grid shows the tier and the dashboard has the Phase 2 widgets', async ({ page }) => { + await loginAdmin(page); + await page.goto('/admin/loyalty/accounts'); + await expect(page.locator('tr', { hasText: 'loyalty@example.com' })).toContainText('Silver'); + + await page.goto('/admin/loyalty'); + await expect(page.locator('[data-test-stat-redemption-rate]')).toBeVisible(); + await expect(page.locator('[data-test-stat-active-accounts]')).toBeVisible(); + await expect(page.locator('[data-test-card-tiers]')).toBeVisible(); + }); +}); diff --git a/infection.json.dist b/infection.json.dist index f5c9da9..9fdbf38 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -4,13 +4,14 @@ "src" ] }, + "testFrameworkOptions": "--testsuite=unit", "logs": { "text": "php://stderr", "github": true, "stryker": { - "badge": "1.12.x" + "badge": "master" } }, - "minMsi": 100.00, - "minCoveredMsi": 100.00 + "minMsi": 20, + "minCoveredMsi": 95 } diff --git a/phpstan.neon b/phpstan.neon index 506b9cf..af05922 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -20,5 +20,19 @@ parameters: repositoryClass: Doctrine\ORM\EntityRepository objectManagerLoader: tests/PHPStan/object_manager.php + ignoreErrors: + # Plugin models deliberately type associations by Sylius interfaces so host projects can + # swap implementations; the object-manager loader resolves those interfaces to the test + # app's concrete classes, which phpstan-doctrine then reports as a type mismatch. + - + identifier: doctrine.associationType + path: src/Model/* + + # The order trait is host-project API; its only in-repo usage is the test application's + # Order entity, which is excluded from analysis. + - + identifier: trait.unused + path: src/Model/LoyaltyOrderTrait.php + reportUnmatchedIgnoredErrors: false treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 83c26e5..934a613 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,12 +8,20 @@ - - tests + + tests/Unit + tests/DependencyInjection + + + tests/Functional + + + + diff --git a/src/Command/CalculateLiabilityCommand.php b/src/Command/CalculateLiabilityCommand.php new file mode 100644 index 0000000..166791f --- /dev/null +++ b/src/Command/CalculateLiabilityCommand.php @@ -0,0 +1,104 @@ + $channelRepository + * @param class-string $accountClass + */ + public function __construct( + private readonly ChannelRepositoryInterface $channelRepository, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly LotReplayerInterface $lotReplayer, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Accounts processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + + /** @var iterable $channels */ + $channels = $this->channelRepository->findAll(); + foreach ($channels as $channel) { + $liability = 0; + $lastId = 0; + while (true) { + /** @var list $batch */ + $batch = $this->entityManager->createQueryBuilder() + ->select('a') + ->from($this->accountClass, 'a') + ->andWhere('a.channel = :channel') + ->andWhere('a.id > :lastId') + ->setParameter('channel', $channel) + ->setParameter('lastId', $lastId) + ->orderBy('a.id', 'ASC') + ->setMaxResults($batchSize) + ->getQuery() + ->getResult() + ; + + if ([] === $batch) { + break; + } + + foreach ($batch as $account) { + $lastId = (int) $account->getId(); + $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account)); + foreach ($replay->getOpenLots() as $lotState) { + $liability += $lotState->getRemaining(); + } + } + + $this->entityManager->clear(); + } + + $program = $this->programProvider->getByChannel($channel); + $program->setLiabilityPoints($liability); + $program->setLiabilityCalculatedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + + $io->text(sprintf('%s: %d points outstanding', (string) $channel->getCode(), $liability)); + } + + $io->success('Liability snapshots updated'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/EvaluateTiersCommand.php b/src/Command/EvaluateTiersCommand.php new file mode 100644 index 0000000..15da7aa --- /dev/null +++ b/src/Command/EvaluateTiersCommand.php @@ -0,0 +1,96 @@ + $accountClass + */ + public function __construct( + private readonly TierEvaluatorInterface $tierEvaluator, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Accounts processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + $now = new \DateTimeImmutable(); + + $accounts = 0; + $changed = 0; + $lastId = 0; + while (true) { + /** @var list $batch */ + $batch = $this->entityManager->createQueryBuilder() + ->select('a') + ->from($this->accountClass, 'a') + ->andWhere('a.enabled = true') + ->andWhere('a.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('a.id', 'ASC') + ->setMaxResults($batchSize) + ->getQuery() + ->getResult() + ; + + if ([] === $batch) { + break; + } + + foreach ($batch as $account) { + ++$accounts; + $lastId = (int) $account->getId(); + + $before = $account->getTier(); + $this->tierEvaluator->reconcile($account, $now); + if ($account->getTier() !== $before) { + ++$changed; + $io->text(sprintf( + 'Account %s: %s -> %s', + (string) $account->getId(), + $before?->getCode() ?? '—', + $account->getTier()?->getCode() ?? '—', + )); + } + } + + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + $io->success(sprintf('Evaluated %d account(s), %d tier change(s)', $accounts, $changed)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/ExpirePointsCommand.php b/src/Command/ExpirePointsCommand.php new file mode 100644 index 0000000..8d2a244 --- /dev/null +++ b/src/Command/ExpirePointsCommand.php @@ -0,0 +1,78 @@ + $accountClass + */ + public function __construct( + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly LoyaltyLedgerInterface $ledger, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Accounts processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + $now = new \DateTimeImmutable(); + + // The candidate ids are collected up front: writing expirations removes accounts from + // the selection, and lots deferred by listeners would otherwise be re-selected forever + $accountIds = []; + $offset = 0; + do { + $batch = $this->transactionRepository->findAccountIdsWithExpiredOpenLots($now, $batchSize, $offset); + $accountIds = [...$accountIds, ...$batch]; + $offset += $batchSize; + } while ([] !== $batch); + + $expired = 0; + foreach ($accountIds as $index => $accountId) { + $account = $this->entityManager->find($this->accountClass, $accountId); + if ($account instanceof LoyaltyAccountInterface) { + $expired += count($this->ledger->expire($account, $now)); + } + + if (0 === ($index + 1) % $batchSize) { + $this->entityManager->clear(); + } + } + + $io->success(sprintf('Wrote %d expiration entrie(s) across %d account(s)', $expired, count($accountIds))); + + return Command::SUCCESS; + } +} diff --git a/src/Command/ExpireReferralsCommand.php b/src/Command/ExpireReferralsCommand.php new file mode 100644 index 0000000..28cdbb3 --- /dev/null +++ b/src/Command/ExpireReferralsCommand.php @@ -0,0 +1,96 @@ +addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Referrals processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + $now = new \DateTimeImmutable(); + + $expired = 0; + // The expiry window is per channel program; iterate pending referrals and compare + // against their own channel's setting + while (true) { + $batch = $this->referralRepository->findPendingOlderThan($now->modify('-1 day'), $batchSize); + $expiredInBatch = 0; + foreach ($batch as $referral) { + $channel = $referral->getChannel(); + $createdAt = $referral->getCreatedAt(); + if (!$channel instanceof ChannelInterface || null === $createdAt) { + continue; + } + + $days = $this->programProvider->getByChannel($channel)->getReferralPendingExpiryDays(); + if ($createdAt->modify(sprintf('+%d days', $days)) <= $now) { + $referral->setStatus(ReferralInterface::STATUS_EXPIRED); + ++$expired; + ++$expiredInBatch; + } + } + + $this->entityManager->flush(); + + if (0 === $expiredInBatch || count($batch) < $batchSize) { + break; + } + } + + $purged = 0; + while (true) { + $batch = $this->referralRepository->findWithIpHashOlderThan($now->modify('-90 days'), $batchSize); + if ([] === $batch) { + break; + } + + foreach ($batch as $referral) { + $referral->setRegistrationIpHash(null); + ++$purged; + } + + $this->entityManager->flush(); + } + + $io->success(sprintf('%d referral(s) expired, %d IP hash(es) purged', $expired, $purged)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/ExportCustomerDataCommand.php b/src/Command/ExportCustomerDataCommand.php new file mode 100644 index 0000000..defcc53 --- /dev/null +++ b/src/Command/ExportCustomerDataCommand.php @@ -0,0 +1,119 @@ + $customerRepository + * @param class-string $accountClass + */ + public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addArgument('email', InputArgument::REQUIRED, 'The customer email'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $customer = $this->customerRepository->findOneBy(['email' => (string) $input->getArgument('email')]); + if (!$customer instanceof CustomerInterface) { + (new SymfonyStyle($input, $output))->error('Customer not found'); + + return Command::FAILURE; + } + + /** @var list $accounts */ + $accounts = $this->entityManager->getRepository($this->accountClass)->findBy(['customer' => $customer]); + + $data = [ + 'email' => $customer->getEmail(), + 'accounts' => array_map($this->exportAccount(...), $accounts), + ]; + + $output->writeln((string) json_encode($data, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + + return Command::SUCCESS; + } + + /** + * @return array + */ + private function exportAccount(LoyaltyAccountInterface $account): array + { + $transactions = []; + foreach ($this->transactionRepository->findForReplay($account) as $transaction) { + $discriminator = $this->entityManager->getClassMetadata($transaction::class)->discriminatorValue; + + $row = [ + 'id' => $transaction->getId(), + 'type' => is_string($discriminator) ? $discriminator : $transaction::class, + 'points' => $transaction->getPoints(), + 'occurredAt' => $transaction->getOccurredAt()->format(\DateTimeInterface::ATOM), + ]; + + if ($transaction instanceof CreditLoyaltyTransactionInterface) { + $row['expiresAt'] = $transaction->getExpiresAt()?->format(\DateTimeInterface::ATOM); + } + + if ($transaction instanceof EarnOrderLoyaltyTransactionInterface || $transaction instanceof RedeemLoyaltyTransactionInterface) { + $row['order'] = $transaction->getOrder()?->getNumber(); + } + + if ($transaction instanceof EarnActionLoyaltyTransactionInterface) { + $row['sourceIdentifier'] = $transaction->getSourceIdentifier(); + } + + if ($transaction instanceof ManualLoyaltyTransactionInterface) { + $row['reason'] = $transaction->getReason(); + $row['note'] = $transaction->getNote(); + } + + $transactions[] = $row; + } + + return [ + 'channel' => $account->getChannel()?->getCode(), + 'enabled' => $account->isEnabled(), + 'balance' => $account->getBalance(), + 'lifetimeEarned' => $account->getLifetimeEarned(), + 'referralCode' => $account->getReferralCode(), + 'createdAt' => $account->getCreatedAt()?->format(\DateTimeInterface::ATOM), + 'transactions' => $transactions, + ]; + } +} diff --git a/src/Command/InspectAccountCommand.php b/src/Command/InspectAccountCommand.php new file mode 100644 index 0000000..fdde5cb --- /dev/null +++ b/src/Command/InspectAccountCommand.php @@ -0,0 +1,133 @@ + $customerRepository + * @param ChannelRepositoryInterface $channelRepository + */ + public function __construct( + private readonly CustomerRepositoryInterface $customerRepository, + private readonly ChannelRepositoryInterface $channelRepository, + private readonly LoyaltyAccountRepositoryInterface $accountRepository, + private readonly AccountInspectorInterface $accountInspector, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('email', InputArgument::REQUIRED, 'The customer email') + ->addArgument('channel', InputArgument::REQUIRED, 'The channel code') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: txt or json', 'txt') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $customer = $this->customerRepository->findOneBy(['email' => (string) $input->getArgument('email')]); + if (!$customer instanceof CustomerInterface) { + $io->error('Customer not found'); + + return Command::FAILURE; + } + + $channel = $this->channelRepository->findOneByCode((string) $input->getArgument('channel')); + if (!$channel instanceof ChannelInterface) { + $io->error('Channel not found'); + + return Command::FAILURE; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null === $account) { + $io->error('The customer has no loyalty account in this channel'); + + return Command::FAILURE; + } + + $inspection = $this->accountInspector->inspect($account); + $data = $inspection->toArray(); + + if ('json' === $input->getOption('format')) { + $output->writeln((string) json_encode($data, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + + return $inspection->isHealthy() ? Command::SUCCESS : Command::FAILURE; + } + + /** @var array $accountData */ + $accountData = $data['account']; + $io->title(sprintf('Loyalty account %s', self::stringify($accountData['id'] ?? ''))); + $io->definitionList(...array_map( + static fn (string $key, mixed $value): array => [$key => var_export($value, true)], + array_keys($accountData), + array_values($accountData), + )); + + /** @var list> $lots */ + $lots = $data['lots']; + $io->section('Lots (replay-derived)'); + $io->table( + ['Lot', 'Points', 'Expires at', 'Remaining', 'Closed by expiration', 'Consumptions'], + array_map( + static fn (array $lot): array => [ + self::stringify($lot['lot'] ?? ''), + self::stringify($lot['points'] ?? ''), + self::stringify($lot['expiresAt'] ?? 'never'), + self::stringify($lot['remaining'] ?? ''), + ($lot['closedByExpiration'] ?? false) === true ? 'yes' : 'no', + (string) json_encode($lot['consumptions'] ?? []), + ], + $lots, + ), + ); + + foreach ($inspection->errors as $error) { + $io->error($error); + } + + foreach ($inspection->warnings as $warning) { + $io->warning($warning); + } + + if ($inspection->isHealthy()) { + $io->success('All ledger invariants hold'); + } + + return $inspection->isHealthy() ? Command::SUCCESS : Command::FAILURE; + } + + private static function stringify(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/src/Command/PruneDryRunResultsCommand.php b/src/Command/PruneDryRunResultsCommand.php new file mode 100644 index 0000000..49ac8cb --- /dev/null +++ b/src/Command/PruneDryRunResultsCommand.php @@ -0,0 +1,61 @@ +addOption('days', null, InputOption::VALUE_REQUIRED, 'Retention period in days', '30'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $days = max(1, (int) $input->getOption('days')); + + /** @var mixed $pruned */ + $pruned = $this->entityManager->createQueryBuilder() + ->delete($this->dryRunResultClass, 'd') + ->andWhere('d.createdAt < :cutoff') + ->setParameter('cutoff', new \DateTimeImmutable(sprintf('-%d days', $days))) + ->getQuery() + ->execute() + ; + + $io->success(sprintf( + 'Pruned %d dry-run result(s) older than %d days', + is_numeric($pruned) ? (int) $pruned : 0, + $days, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Command/RecalculateBalancesCommand.php b/src/Command/RecalculateBalancesCommand.php new file mode 100644 index 0000000..6cf8fac --- /dev/null +++ b/src/Command/RecalculateBalancesCommand.php @@ -0,0 +1,110 @@ + $accountClass + */ + public function __construct( + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Accounts processed per batch', '100') + ->addOption('force', null, InputOption::VALUE_NONE, 'Write the derived balance where it drifted') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + $force = (bool) $input->getOption('force'); + + $accounts = 0; + $drifted = 0; + $offset = 0; + while (true) { + /** @var list $batch */ + $batch = $this->entityManager->createQueryBuilder() + ->select('a') + ->from($this->accountClass, 'a') + ->orderBy('a.id', 'ASC') + ->setFirstResult($offset) + ->setMaxResults($batchSize) + ->getQuery() + ->getResult() + ; + + if ([] === $batch) { + break; + } + + foreach ($batch as $account) { + ++$accounts; + $derived = $this->transactionRepository->sumPoints($account); + if ($derived === $account->getBalance()) { + continue; + } + + ++$drifted; + $io->warning(sprintf( + 'Account %s: cached balance %d, ledger sum %d (drift %+d)%s', + (string) $account->getId(), + $account->getBalance(), + $derived, + $account->getBalance() - $derived, + $force ? ' — corrected' : '', + )); + + if ($force) { + $account->setBalance($derived); + } + } + + if ($force) { + $this->entityManager->flush(); + } + $this->entityManager->clear(); + $offset += $batchSize; + } + + if ($drifted > 0 && !$force) { + $io->error(sprintf('%d of %d account(s) drifted; run with --force to correct', $drifted, $accounts)); + + return Command::FAILURE; + } + + $io->success(sprintf('Checked %d account(s), %d drifted', $accounts, $drifted)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/TriggerBirthdaysCommand.php b/src/Command/TriggerBirthdaysCommand.php new file mode 100644 index 0000000..57c2a38 --- /dev/null +++ b/src/Command/TriggerBirthdaysCommand.php @@ -0,0 +1,106 @@ +") makes re-runs on the same + * day no-ops. Customers born on February 29th are only matched in leap years. + */ +#[AsCommand( + name: 'setono:sylius-loyalty:trigger-birthdays', + description: 'Fires the customer_birthday earning trigger for customers whose birthday is today', +)] +final class TriggerBirthdaysCommand extends Command +{ + /** + * @param class-string $customerClass + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly string $customerClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Customers processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + + $today = new \DateTimeImmutable('today'); + $year = (int) $today->format('Y'); + + $dispatched = 0; + $offset = 0; + while (true) { + $customerIds = $this->customerIds($today, $batchSize, $offset); + if ([] === $customerIds) { + break; + } + + foreach ($customerIds as $customerId) { + $customer = $this->entityManager->find($this->customerClass, $customerId); + if (!$customer instanceof CustomerInterface) { + continue; + } + + $this->eventDispatcher->dispatch(new CustomerBirthdayTriggerEvent($customer, $year)); + ++$dispatched; + } + + $this->entityManager->clear(); + $offset += $batchSize; + } + + $io->success(sprintf('Dispatched %d birthday trigger(s)', $dispatched)); + + return Command::SUCCESS; + } + + /** + * DQL has no month/day functions, so the candidates are selected with native SQL (the + * plugin only ever targets MySQL). + * + * @return list + */ + private function customerIds(\DateTimeImmutable $today, int $limit, int $offset): array + { + $table = $this->entityManager->getClassMetadata($this->customerClass)->getTableName(); + + /** @var list $rows */ + $rows = $this->entityManager->getConnection()->fetchAllAssociative( + sprintf( + 'SELECT id FROM %s WHERE birthday IS NOT NULL AND MONTH(birthday) = :month AND DAY(birthday) = :day ORDER BY id ASC LIMIT %d OFFSET %d', + $table, + $limit, + $offset, + ), + [ + 'month' => (int) $today->format('n'), + 'day' => (int) $today->format('j'), + ], + ); + + return array_map(static fn (array $row): int => (int) $row['id'], $rows); + } +} diff --git a/src/Command/VerifyLedgerCommand.php b/src/Command/VerifyLedgerCommand.php new file mode 100644 index 0000000..73f1c1d --- /dev/null +++ b/src/Command/VerifyLedgerCommand.php @@ -0,0 +1,98 @@ + $accountClass + */ + public function __construct( + private readonly AccountInspectorInterface $accountInspector, + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Accounts processed per batch', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = max(1, (int) $input->getOption('batch-size')); + $now = new \DateTimeImmutable(); + + $accounts = 0; + $errors = 0; + $warnings = 0; + $offset = 0; + while (true) { + /** @var list $batch */ + $batch = $this->entityManager->createQueryBuilder() + ->select('a') + ->from($this->accountClass, 'a') + ->orderBy('a.id', 'ASC') + ->setFirstResult($offset) + ->setMaxResults($batchSize) + ->getQuery() + ->getResult() + ; + + if ([] === $batch) { + break; + } + + foreach ($batch as $account) { + ++$accounts; + $inspection = $this->accountInspector->inspect($account, $now); + + foreach ($inspection->errors as $error) { + ++$errors; + $io->error(sprintf('Account %s: %s', (string) $account->getId(), $error)); + } + + foreach ($inspection->warnings as $warning) { + ++$warnings; + $io->warning(sprintf('Account %s: %s', (string) $account->getId(), $warning)); + } + } + + $this->entityManager->clear(); + $offset += $batchSize; + } + + if ($errors > 0) { + $io->error(sprintf('%d invariant violation(s) across %d account(s)', $errors, $accounts)); + + return Command::FAILURE; + } + + $io->success(sprintf('Verified %d account(s): no violations, %d warning(s)', $accounts, $warnings)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Action/Admin/DashboardAction.php b/src/Controller/Action/Admin/DashboardAction.php new file mode 100644 index 0000000..229a9aa --- /dev/null +++ b/src/Controller/Action/Admin/DashboardAction.php @@ -0,0 +1,25 @@ +twig->render('@SetonoSyliusLoyaltyPlugin/admin/dashboard/index.html.twig', [ + 'stats' => $this->statsProvider->getStats(), + ])); + } +} diff --git a/src/Controller/Action/Admin/InspectAccountAction.php b/src/Controller/Action/Admin/InspectAccountAction.php new file mode 100644 index 0000000..c661884 --- /dev/null +++ b/src/Controller/Action/Admin/InspectAccountAction.php @@ -0,0 +1,50 @@ + $accountClass + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly AccountInspectorInterface $accountInspector, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly Environment $twig, + private readonly string $accountClass, + /** @var list */ + private readonly array $manualAdjustmentReasons, + ) { + } + + public function __invoke(int $id): Response + { + $account = $this->entityManager->find($this->accountClass, $id); + if (!$account instanceof LoyaltyAccountInterface) { + throw new NotFoundHttpException('Loyalty account not found'); + } + + return new Response($this->twig->render('@SetonoSyliusLoyaltyPlugin/admin/account/inspect.html.twig', [ + 'account' => $account, + 'inspection' => $this->accountInspector->inspect($account), + 'transactions' => array_reverse($this->transactionRepository->findForReplay($account)), + 'reasons' => $this->manualAdjustmentReasons, + ])); + } +} diff --git a/src/Controller/Action/Admin/LintExpressionAction.php b/src/Controller/Action/Admin/LintExpressionAction.php new file mode 100644 index 0000000..85c4bdf --- /dev/null +++ b/src/Controller/Action/Admin/LintExpressionAction.php @@ -0,0 +1,49 @@ +getContent(), true); + if (!is_array($payload)) { + return new JsonResponse(['diagnostics' => [['message' => 'Invalid request']]], Response::HTTP_BAD_REQUEST); + } + + $expression = $payload['expression'] ?? null; + if (!is_string($expression) || '' === trim($expression)) { + return new JsonResponse(['diagnostics' => []]); + } + + $trigger = $payload['trigger'] ?? null; + $trigger = is_string($trigger) && '' !== $trigger ? $trigger : null; + + try { + $this->validator->validate($expression, $trigger); + } catch (InvalidExpressionException $e) { + return new JsonResponse(['diagnostics' => [['message' => $e->getMessage()]]]); + } + + return new JsonResponse(['diagnostics' => []]); + } +} diff --git a/src/Controller/Action/Admin/ManualAdjustmentAction.php b/src/Controller/Action/Admin/ManualAdjustmentAction.php new file mode 100644 index 0000000..69a7483 --- /dev/null +++ b/src/Controller/Action/Admin/ManualAdjustmentAction.php @@ -0,0 +1,87 @@ + $accountClass + * @param list $reasons + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LoyaltyLedgerInterface $ledger, + private readonly Security $security, + private readonly CsrfTokenManagerInterface $csrfTokenManager, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly string $accountClass, + private readonly array $reasons, + ) { + } + + public function __invoke(Request $request, int $id): Response + { + $account = $this->entityManager->find($this->accountClass, $id); + if (!$account instanceof LoyaltyAccountInterface) { + throw new NotFoundHttpException('Loyalty account not found'); + } + + $redirect = new RedirectResponse($this->urlGenerator->generate('setono_sylius_loyalty_admin_account_inspect', ['id' => $id])); + + $token = (string) $request->request->get('_csrf_token'); + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken('setono_sylius_loyalty_admin', $token))) { + return $redirect; + } + + $points = (int) $request->request->get('points'); + $reason = (string) $request->request->get('reason'); + $note = trim((string) $request->request->get('note')); + + if (0 === $points || '' === $note || !in_array($reason, $this->reasons, true)) { + $this->flash($request, 'error', 'setono_sylius_loyalty.adjustment_invalid'); + + return $redirect; + } + + $adminUser = $this->security->getUser(); + $adminUser = $adminUser instanceof AdminUserInterface ? $adminUser : null; + + if ($points > 0) { + $this->ledger->manualCredit($account, $points, $reason, $note, $adminUser); + } else { + $this->ledger->manualDebit($account, -$points, $reason, $note, $adminUser); + } + + $this->flash($request, 'success', 'setono_sylius_loyalty.adjustment_applied'); + + return $redirect; + } + + private function flash(Request $request, string $type, string $message): void + { + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add($type, $message); + } + } +} diff --git a/src/Controller/Action/Admin/OverrideReferralAction.php b/src/Controller/Action/Admin/OverrideReferralAction.php new file mode 100644 index 0000000..b0ac39c --- /dev/null +++ b/src/Controller/Action/Admin/OverrideReferralAction.php @@ -0,0 +1,64 @@ +referralRepository->find($id); + if (!$referral instanceof ReferralInterface) { + throw new NotFoundHttpException(); + } + + $response = new RedirectResponse($this->urlGenerator->generate('setono_sylius_loyalty_admin_referral_index')); + + $session = $request->getSession(); + \assert($session instanceof Session); + + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken('setono_sylius_loyalty_admin', (string) $request->request->get('_csrf_token')))) { + $session->getFlashBag()->add('error', 'setono_sylius_loyalty.referral_override_invalid'); + + return $response; + } + + if (ReferralInterface::STATUS_REJECTED !== $referral->getStatus()) { + $session->getFlashBag()->add('error', 'setono_sylius_loyalty.referral_override_not_rejected'); + + return $response; + } + + $referral->setFraudFlags([]); + $this->referralQualifier->requalify($referral); + + $session->getFlashBag()->add('success', 'setono_sylius_loyalty.referral_overridden'); + + return $response; + } +} diff --git a/src/Controller/Action/Admin/ProgramIndexAction.php b/src/Controller/Action/Admin/ProgramIndexAction.php new file mode 100644 index 0000000..e67f862 --- /dev/null +++ b/src/Controller/Action/Admin/ProgramIndexAction.php @@ -0,0 +1,52 @@ + $channelRepository + */ + public function __construct( + private readonly ChannelRepositoryInterface $channelRepository, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly Environment $twig, + ) { + } + + public function __invoke(): Response + { + /** @var list $programs */ + $programs = []; + foreach ($this->channelRepository->findAll() as $channel) { + if ($channel instanceof ChannelInterface) { + $programs[] = [ + 'channel' => $channel, + 'program' => $this->programProvider->getByChannel($channel), + ]; + } + } + + return new Response($this->twig->render('@SetonoSyliusLoyaltyPlugin/admin/program/index.html.twig', [ + 'programs' => $programs, + ])); + } +} diff --git a/src/Controller/Action/Admin/RuleTesterAction.php b/src/Controller/Action/Admin/RuleTesterAction.php new file mode 100644 index 0000000..a86a903 --- /dev/null +++ b/src/Controller/Action/Admin/RuleTesterAction.php @@ -0,0 +1,112 @@ + $orderRepository + */ + public function __construct( + private readonly OrderRepositoryInterface $orderRepository, + private readonly EarningRuleRepositoryInterface $ruleRepository, + private readonly LoyaltyAccountRepositoryInterface $accountRepository, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly EligibleBasisCalculatorInterface $basisCalculator, + private readonly EarningRuleEvaluatorInterface $evaluator, + private readonly Environment $twig, + ) { + } + + public function __invoke(Request $request): Response + { + $orderNumber = trim((string) $request->query->get('order', '')); + $evaluateAt = trim((string) $request->query->get('evaluate_at', '')); + + $error = null; + $order = null; + $result = null; + + if ('' !== $orderNumber) { + [$order, $result, $error] = $this->evaluate($orderNumber, $evaluateAt); + } + + return new Response($this->twig->render('@SetonoSyliusLoyaltyPlugin/admin/rule_tester/index.html.twig', [ + 'orderNumber' => $orderNumber, + 'evaluateAt' => $evaluateAt, + 'order' => $order, + 'result' => $result, + 'error' => $error, + ])); + } + + /** + * @return array{0: OrderInterface|null, 1: EvaluationResult|null, 2: string|null} + */ + private function evaluate(string $orderNumber, string $evaluateAt): array + { + $order = $this->orderRepository->findOneBy(['number' => $orderNumber]); + if (!$order instanceof OrderInterface) { + return [null, null, 'setono_sylius_loyalty.ui.tester_order_not_found']; + } + + $customer = $order->getCustomer(); + $channel = $order->getChannel(); + if (!$customer instanceof CustomerInterface || null === $channel) { + return [$order, null, 'setono_sylius_loyalty.ui.tester_order_has_no_customer']; + } + + $now = null; + if ('' !== $evaluateAt) { + try { + $now = new \DateTimeImmutable($evaluateAt); + } catch (\Exception) { + return [$order, null, 'setono_sylius_loyalty.ui.tester_invalid_date']; + } + } + + $program = $this->programProvider->getByChannel($channel); + $basis = $this->basisCalculator->calculate($order, $program); + + $context = new EarningContext( + channel: $channel, + customer: $customer, + account: $this->accountRepository->findOneByCustomerAndChannel($customer, $channel), + order: $order, + itemAmounts: $basis->itemAmounts, + now: $now, + extraAmount: $basis->extraAmount, + ); + + $result = $this->evaluator->evaluate( + $this->ruleRepository->findForEvaluation($channel, EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE), + $context, + $program, + ); + + return [$order, $result, null]; + } +} diff --git a/src/Controller/Action/Admin/ToggleAccountAction.php b/src/Controller/Action/Admin/ToggleAccountAction.php new file mode 100644 index 0000000..d03ef8b --- /dev/null +++ b/src/Controller/Action/Admin/ToggleAccountAction.php @@ -0,0 +1,56 @@ + $accountRepository + */ + public function __construct( + private readonly RepositoryInterface $accountRepository, + private readonly CsrfTokenManagerInterface $csrfTokenManager, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function __invoke(Request $request, int $id): Response + { + $account = $this->accountRepository->find($id); + if (!$account instanceof LoyaltyAccountInterface) { + throw new NotFoundHttpException('Loyalty account not found'); + } + + $token = (string) $request->request->get('_csrf_token'); + if ($this->csrfTokenManager->isTokenValid(new CsrfToken('setono_sylius_loyalty_admin', $token))) { + $account->setEnabled(!$account->isEnabled()); + $this->accountRepository->add($account); + + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add('success', $account->isEnabled() + ? 'setono_sylius_loyalty.account_enabled' + : 'setono_sylius_loyalty.account_disabled'); + } + } + + return new RedirectResponse($this->urlGenerator->generate('setono_sylius_loyalty_admin_account_inspect', ['id' => $id])); + } +} diff --git a/src/Controller/Action/Shop/ApplyRedemptionAction.php b/src/Controller/Action/Shop/ApplyRedemptionAction.php new file mode 100644 index 0000000..a3f09e8 --- /dev/null +++ b/src/Controller/Action/Shop/ApplyRedemptionAction.php @@ -0,0 +1,103 @@ +urlGenerator->generate('sylius_shop_cart_summary')); + + $token = (string) $request->request->get('_csrf_token'); + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken(self::CSRF_TOKEN_ID, $token))) { + $this->flash($request, 'error', 'setono_sylius_loyalty.redemption_invalid_request'); + + return $redirect; + } + + $cart = $this->cartContext->getCart(); + $customer = $cart instanceof OrderInterface ? $cart->getCustomer() : null; + $channel = $cart instanceof OrderInterface ? $cart->getChannel() : null; + + if (!$cart instanceof OrderInterface || + !$cart instanceof LoyaltyOrderInterface || + !$customer instanceof CustomerInterface || + null === $channel + ) { + $this->flash($request, 'error', 'setono_sylius_loyalty.redemption_not_available'); + + return $redirect; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null === $account || !$account->isEnabled()) { + $this->flash($request, 'error', 'setono_sylius_loyalty.redemption_not_available'); + + return $redirect; + } + + $points = $request->request->getBoolean('use_max') + ? $account->getBalance() + : (int) $request->request->get('points'); + + $program = $this->programProvider->getByChannel($channel); + + if ($points < $program->getMinRedeemPoints() || $points > $account->getBalance()) { + $this->flash($request, 'error', 'setono_sylius_loyalty.redemption_invalid_points'); + + return $redirect; + } + + $cart->setLoyaltyPointsRequested($points); + $this->orderProcessor->process($cart); + $this->entityManager->flush(); + + $this->flash($request, 'success', 'setono_sylius_loyalty.redemption_applied'); + + return $redirect; + } + + private function flash(Request $request, string $type, string $message): void + { + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add($type, $message); + } + } +} diff --git a/src/Controller/Action/Shop/LoyaltyDashboardAction.php b/src/Controller/Action/Shop/LoyaltyDashboardAction.php new file mode 100644 index 0000000..39b711e --- /dev/null +++ b/src/Controller/Action/Shop/LoyaltyDashboardAction.php @@ -0,0 +1,137 @@ +customerContext->getCustomer(); + if (!$customer instanceof CustomerInterface) { + throw new AccessDeniedHttpException(); + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $this->channelContext->getChannel()); + + $page = max(1, $request->query->getInt('page', 1)); + $transactions = null === $account ? [] : $this->transactionRepository->findHistoryPage($account, $page, self::PAGE_SIZE); + $total = null === $account ? 0 : $this->transactionRepository->countHistory($account); + + return new Response($this->twig->render('@SetonoSyliusLoyaltyPlugin/shop/account/loyalty/index.html.twig', [ + 'account' => $account, + 'rows' => $this->rows($account, $transactions), + 'page' => $page, + 'pages' => (int) ceil($total / self::PAGE_SIZE), + 'expiringSoon' => $this->expiringSoon($account), + 'tierProgress' => null === $account ? null : $this->tierProgressProvider->getProgress($account), + 'referral' => null === $account ? null : $this->referralBlock($account), + ])); + } + + /** + * @param list $transactions + * + * @return list + */ + private function rows(?LoyaltyAccountInterface $account, array $transactions): array + { + if (null === $account || [] === $transactions) { + return []; + } + + $runningBalance = $account->getBalance() - $this->transactionRepository->sumPointsNewerThan($account, $transactions[0]); + + $rows = []; + foreach ($transactions as $transaction) { + $rows[] = [ + 'transaction' => $transaction, + 'runningBalance' => $runningBalance, + ]; + $runningBalance -= $transaction->getPoints(); + } + + return $rows; + } + + /** + * @return array{code: string, shareUrl: string, stats: array{rewarded: int, pointsEarned: int}} + */ + private function referralBlock(LoyaltyAccountInterface $account): array + { + $code = $this->referralCodeGenerator->getCode($account); + + return [ + 'code' => $code, + 'shareUrl' => $this->urlGenerator->generate( + 'setono_sylius_loyalty_shop_referral_landing', + ['code' => $code], + \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL, + ), + 'stats' => $this->referralRepository->getReferrerStats($account), + ]; + } + + /** + * @return array{points: int, until: \DateTimeImmutable}|null + */ + private function expiringSoon(?LoyaltyAccountInterface $account): ?array + { + if (null === $account) { + return null; + } + + $now = new \DateTimeImmutable(); + $horizon = $now->modify(sprintf('+%d days', self::EXPIRING_SOON_DAYS)); + + $points = 0; + foreach ($this->lotReplayer->replay($this->transactionRepository->findForReplay($account))->getOpenLots() as $lotState) { + $expiresAt = $lotState->lot->getExpiresAt(); + if (null !== $expiresAt && $expiresAt > $now && $expiresAt <= $horizon) { + $points += $lotState->getRemaining(); + } + } + + return $points > 0 ? ['points' => $points, 'until' => $horizon] : null; + } +} diff --git a/src/Controller/Action/Shop/ReferralLandingAction.php b/src/Controller/Action/Shop/ReferralLandingAction.php new file mode 100644 index 0000000..32679bf --- /dev/null +++ b/src/Controller/Action/Shop/ReferralLandingAction.php @@ -0,0 +1,41 @@ + $accountRepository + */ + public function __construct( + private readonly RepositoryInterface $accountRepository, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function __invoke(Request $request, string $code): Response + { + $response = new RedirectResponse($this->urlGenerator->generate('sylius_shop_homepage')); + + $code = strtoupper($code); + if (AttributionCookie::isValidFormat($code) && null !== $this->accountRepository->findOneBy(['referralCode' => $code])) { + $response->headers->setCookie(AttributionCookie::create($code)); + } + + return $response; + } +} diff --git a/src/Controller/Action/Shop/RemoveRedemptionAction.php b/src/Controller/Action/Shop/RemoveRedemptionAction.php new file mode 100644 index 0000000..2a84b9b --- /dev/null +++ b/src/Controller/Action/Shop/RemoveRedemptionAction.php @@ -0,0 +1,53 @@ +urlGenerator->generate('sylius_shop_cart_summary')); + + $token = (string) $request->request->get('_csrf_token'); + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken(ApplyRedemptionAction::CSRF_TOKEN_ID, $token))) { + return $redirect; + } + + $cart = $this->cartContext->getCart(); + if ($cart instanceof LoyaltyOrderInterface) { + $cart->setLoyaltyPointsRequested(null); + $this->orderProcessor->process($cart); + $this->entityManager->flush(); + + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add('success', 'setono_sylius_loyalty.redemption_removed'); + } + } + + return $redirect; + } +} diff --git a/src/DependencyInjection/Compiler/RegisterEarningTriggersPass.php b/src/DependencyInjection/Compiler/RegisterEarningTriggersPass.php new file mode 100644 index 0000000..c991ac1 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterEarningTriggersPass.php @@ -0,0 +1,126 @@ +hasDefinition(EarningTriggerListener::class)) { + return; + } + + /** @var list $configuredTriggers */ + $configuredTriggers = $container->getParameter('setono_sylius_loyalty.triggers'); + + $triggers = array_values(array_unique([...self::BUILT_IN_TRIGGERS, ...$configuredTriggers])); + + $listener = $container->getDefinition(EarningTriggerListener::class); + + $catalog = []; + foreach ($triggers as $trigger) { + $code = self::validate($trigger, $catalog); + + $catalog[$code] = [ + 'class' => $trigger, + 'label' => $trigger::getLabel(), + 'context' => self::contextProperties($trigger), + ]; + + $listener->addTag('kernel.event_listener', [ + 'event' => $trigger, + 'method' => '__invoke', + ]); + } + + $container->setParameter('setono_sylius_loyalty.trigger_catalog', $catalog); + } + + /** + * @param array $catalog + * + * @phpstan-assert class-string $trigger + * + * @return string the trigger code + */ + private static function validate(string $trigger, array $catalog): string + { + if (!class_exists($trigger)) { + throw new InvalidTriggerException(sprintf('The configured trigger "%s" does not exist', $trigger)); + } + + if (!is_subclass_of($trigger, EarningTriggerEvent::class)) { + throw new InvalidTriggerException(sprintf( + 'The configured trigger "%s" must extend %s', + $trigger, + EarningTriggerEvent::class, + )); + } + + if ((new \ReflectionClass($trigger))->isAbstract()) { + throw new InvalidTriggerException(sprintf('The configured trigger "%s" must not be abstract', $trigger)); + } + + $code = $trigger::getCode(); + if (isset($catalog[$code])) { + throw new InvalidTriggerException(sprintf( + 'The trigger code "%s" of "%s" collides with the already registered "%s"', + $code, + $trigger, + is_array($catalog[$code]) && is_string($catalog[$code]['class'] ?? null) ? $catalog[$code]['class'] : '?', + )); + } + + return $code; + } + + /** + * The subclass's own public properties are the trigger's typed expression context. + * + * @param class-string $trigger + * + * @return array property name => type name + */ + private static function contextProperties(string $trigger): array + { + $properties = []; + foreach ((new \ReflectionClass($trigger))->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if (EarningTriggerEvent::class === $property->getDeclaringClass()->getName()) { + continue; + } + + $type = $property->getType(); + + $properties[$property->getName()] = $type instanceof \ReflectionNamedType ? $type->getName() : 'mixed'; + } + + return $properties; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 5c9496d..e5e165d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,7 +4,38 @@ namespace Setono\SyliusLoyaltyPlugin\DependencyInjection; +use Setono\SyliusLoyaltyPlugin\Form\Type\EarningRuleConditionType; +use Setono\SyliusLoyaltyPlugin\Form\Type\EarningRuleType; +use Setono\SyliusLoyaltyPlugin\Form\Type\LoyaltyProgramType; +use Setono\SyliusLoyaltyPlugin\Form\Type\TierType; +use Setono\SyliusLoyaltyPlugin\Model\DryRunResult; +use Setono\SyliusLoyaltyPlugin\Model\DryRunResultInterface; +use Setono\SyliusLoyaltyPlugin\Model\EarningRule; +use Setono\SyliusLoyaltyPlugin\Model\EarningRuleCondition; +use Setono\SyliusLoyaltyPlugin\Model\EarningRuleConditionInterface; +use Setono\SyliusLoyaltyPlugin\Model\EarningRuleInterface; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyAccount; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyAccountInterface; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyProgram; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyProgramInterface; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyTransaction; +use Setono\SyliusLoyaltyPlugin\Model\LoyaltyTransactionInterface; +use Setono\SyliusLoyaltyPlugin\Model\Referral; +use Setono\SyliusLoyaltyPlugin\Model\ReferralInterface; +use Setono\SyliusLoyaltyPlugin\Model\Tier; +use Setono\SyliusLoyaltyPlugin\Model\TierInterface; +use Setono\SyliusLoyaltyPlugin\Model\TierTranslation; +use Setono\SyliusLoyaltyPlugin\Model\TierTranslationInterface; +use Setono\SyliusLoyaltyPlugin\Repository\EarningRuleRepository; +use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyAccountRepository; +use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyProgramRepository; +use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyTransactionRepository; +use Setono\SyliusLoyaltyPlugin\Repository\ReferralRepository; +use Setono\SyliusLoyaltyPlugin\Repository\TierRepository; +use Sylius\Bundle\ResourceBundle\Controller\ResourceController; +use Sylius\Component\Resource\Factory\Factory; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -18,13 +49,144 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode = $treeBuilder->getRootNode(); $rootNode + ->addDefaultsIfNotSet() ->children() - ->scalarNode('option') - ->info('This is an example configuration option') - ->isRequired() - ->cannotBeEmpty() + ->arrayNode('manual_adjustment_reasons') + ->info('Reason codes selectable when an admin manually adjusts a loyalty account. Labels resolve via the translation key "setono_sylius_loyalty.ui.manual_reason."') + ->defaultValue(['goodwill', 'correction', 'promotion', 'other']) + ->scalarPrototype()->cannotBeEmpty()->end() + ->end() + ->arrayNode('triggers') + ->info('Earning trigger event classes. Each class must extend Setono\SyliusLoyaltyPlugin\Event\Trigger\EarningTriggerEvent') + ->defaultValue([]) + ->scalarPrototype()->cannotBeEmpty()->end() + ->end() + ->booleanNode('retain_anonymized_ledger') + ->info('On customer deletion, keep de-identified ledger rows (type, points, dates, channel) linked to an opaque account token for accounting continuity, instead of deleting everything (the default)') + ->defaultFalse() + ->end() + ->arrayNode('referral') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('query_parameter') + ->info('The query parameter recognized as a referral code on any shop URL, e.g. /products/foo?ref=CODE') + ->defaultValue('ref') + ->cannotBeEmpty() + ->end() + ->booleanNode('registration_ip_check') + ->info('Enable the registration-IP fraud check. Opt-in: it stores a salted IP hash on the referral, purged after 90 days') + ->defaultFalse() + ->end() + ->scalarNode('ip_hash_salt') + ->info('Salt for the registration-IP hash; set your own secret when enabling the IP check') + ->defaultValue('%kernel.secret%') + ->cannotBeEmpty() + ->end() + ->integerNode('reward_cap') + ->info('Maximum rewarded referrals per referrer per 30 days before the cap fraud check flags') + ->defaultValue(10) + ->end() + ->end() + ->end() + ->arrayNode('expression_editor') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('cdn_base_url') + ->info('Base URL for the version-pinned ESM imports (CodeMirror) used by the admin expression editor. Point it at a self-hosted copy for intranet or strict-CSP setups') + ->defaultValue('https://esm.sh') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() ; + $this->addResourcesSection($rootNode); + return $treeBuilder; } + + private function addResourcesSection(ArrayNodeDefinition $rootNode): void + { + /** @var NodeBuilder $resources */ + $resources = $rootNode + ->children() + ->arrayNode('resources') + ->addDefaultsIfNotSet() + ->children() + ; + + $this->addResourceNode($resources, 'account', LoyaltyAccount::class, LoyaltyAccountInterface::class, LoyaltyAccountRepository::class); + $this->addResourceNode($resources, 'program', LoyaltyProgram::class, LoyaltyProgramInterface::class, LoyaltyProgramRepository::class, LoyaltyProgramType::class); + $this->addResourceNode($resources, 'transaction', LoyaltyTransaction::class, LoyaltyTransactionInterface::class, LoyaltyTransactionRepository::class); + $this->addResourceNode($resources, 'earning_rule', EarningRule::class, EarningRuleInterface::class, EarningRuleRepository::class, EarningRuleType::class); + $this->addResourceNode($resources, 'earning_rule_condition', EarningRuleCondition::class, EarningRuleConditionInterface::class, null, EarningRuleConditionType::class); + $this->addResourceNode($resources, 'dry_run_result', DryRunResult::class, DryRunResultInterface::class); + $this->addResourceNode($resources, 'referral', Referral::class, ReferralInterface::class, ReferralRepository::class); + $this->addResourceNode($resources, 'tier', Tier::class, TierInterface::class, TierRepository::class, TierType::class, [ + 'model' => TierTranslation::class, + 'interface' => TierTranslationInterface::class, + ]); + } + + /** + * @param class-string $model + * @param class-string $interface + * @param class-string|null $repository + * @param class-string|null $form + * @param array{model: class-string, interface: class-string}|null $translation + */ + private function addResourceNode( + NodeBuilder $resources, + string $name, + string $model, + string $interface, + ?string $repository = null, + ?string $form = null, + ?array $translation = null, + ): void { + $resourceNode = $resources->arrayNode($name)->addDefaultsIfNotSet(); + $resourceChildren = $resourceNode->children(); + $resourceChildren->variableNode('options'); + + $classes = $resourceChildren + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ; + + $classes->scalarNode('model')->defaultValue($model)->cannotBeEmpty(); + $classes->scalarNode('interface')->defaultValue($interface)->cannotBeEmpty(); + $classes->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty(); + $classes->scalarNode('factory')->defaultValue(Factory::class)->cannotBeEmpty(); + + $formNode = $classes->scalarNode('form')->cannotBeEmpty(); + if (null !== $form) { + $formNode->defaultValue($form); + } + + $repositoryNode = $classes->scalarNode('repository')->cannotBeEmpty(); + if (null !== $repository) { + $repositoryNode->defaultValue($repository); + } + + if (null !== $translation) { + $translationClasses = $resourceChildren + ->arrayNode('translation') + ->addDefaultsIfNotSet() + ->children() + ->variableNode('options')->end() + ->arrayNode('classes') + ->addDefaultsIfNotSet() + ->children() + ; + + $translationClasses->scalarNode('model')->defaultValue($translation['model'])->cannotBeEmpty(); + $translationClasses->scalarNode('interface')->defaultValue($translation['interface'])->cannotBeEmpty(); + $translationClasses->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty(); + $translationClasses->scalarNode('factory')->defaultValue(Factory::class)->cannotBeEmpty(); + $translationClasses->scalarNode('repository')->cannotBeEmpty(); + $translationClasses->scalarNode('form')->cannotBeEmpty(); + } + } } diff --git a/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php b/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php index 2398fd5..3a1ccdc 100644 --- a/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php +++ b/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php @@ -4,25 +4,482 @@ namespace Setono\SyliusLoyaltyPlugin\DependencyInjection; +use Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculatorInterface; +use Setono\SyliusLoyaltyPlugin\EarningRule\Checker\ConditionCheckerInterface; +use Setono\SyliusLoyaltyPlugin\Expression\Function\ExpressionFunctionInterface; +use Setono\SyliusLoyaltyPlugin\Referral\FraudCheck\ReferralFraudCheckInterface; +use Setono\SyliusLoyaltyPlugin\Tier\QualificationBasis\TierQualificationBasisInterface; +use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension; +use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; -final class SetonoSyliusLoyaltyExtension extends Extension +final class SetonoSyliusLoyaltyExtension extends AbstractResourceExtension implements PrependExtensionInterface { + public function prepend(ContainerBuilder $container): void + { + $this->prependWinzouStateMachineConfig($container); + $this->prependSyliusUiConfig($container); + $this->prependSyliusGridConfig($container); + + if ($container->hasExtension('twig')) { + $container->prependExtensionConfig('twig', [ + 'form_themes' => ['@SetonoSyliusLoyaltyPlugin/form/theme.html.twig'], + ]); + } + } + + /** + * Registers the plugin's admin grids. + */ + private function prependSyliusGridConfig(ContainerBuilder $container): void + { + if (!$container->hasExtension('sylius_grid')) { + return; + } + + $container->prependExtensionConfig('sylius_grid', [ + 'grids' => [ + 'setono_sylius_loyalty_admin_account' => [ + 'driver' => [ + 'name' => 'doctrine/orm', + 'options' => ['class' => '%setono_sylius_loyalty.model.account.class%'], + ], + 'sorting' => ['balance' => 'desc'], + 'fields' => [ + 'customer' => [ + 'type' => 'string', + 'label' => 'sylius.ui.customer', + 'path' => 'customer.email', + 'sortable' => 'customer.email', + ], + 'channel' => [ + 'type' => 'string', + 'label' => 'sylius.ui.channel', + 'path' => 'channel.code', + ], + 'balance' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.balance_label', + 'sortable' => 'balance', + ], + 'lifetimeEarned' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.lifetime_earned', + 'sortable' => 'lifetimeEarned', + ], + 'tier' => [ + 'type' => 'twig', + 'label' => 'setono_sylius_loyalty.ui.tier', + 'path' => 'tier', + 'options' => ['template' => '@SetonoSyliusLoyaltyPlugin/admin/grid/field/tier.html.twig'], + ], + 'enabled' => [ + 'type' => 'twig', + 'label' => 'sylius.ui.enabled', + 'sortable' => 'enabled', + 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'], + ], + ], + 'filters' => [ + 'customer' => [ + 'type' => 'string', + 'label' => 'sylius.ui.customer', + 'options' => ['fields' => ['customer.email']], + ], + 'enabled' => [ + 'type' => 'boolean', + 'label' => 'sylius.ui.enabled', + ], + ], + 'actions' => [ + 'item' => [ + 'inspect' => [ + 'type' => 'show', + 'label' => 'setono_sylius_loyalty.ui.inspect', + 'options' => [ + 'link' => [ + 'route' => 'setono_sylius_loyalty_admin_account_inspect', + 'parameters' => ['id' => 'resource.id'], + ], + ], + ], + ], + ], + ], + 'setono_sylius_loyalty_admin_earning_rule' => [ + 'driver' => [ + 'name' => 'doctrine/orm', + 'options' => ['class' => '%setono_sylius_loyalty.model.earning_rule.class%'], + ], + 'sorting' => ['priority' => 'desc'], + 'fields' => [ + 'name' => [ + 'type' => 'string', + 'label' => 'sylius.ui.name', + 'sortable' => 'name', + ], + 'trigger' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.trigger', + 'sortable' => 'trigger', + ], + 'scope' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.scope', + ], + 'priority' => [ + 'type' => 'string', + 'label' => 'sylius.ui.priority', + 'sortable' => 'priority', + ], + 'enabled' => [ + 'type' => 'twig', + 'label' => 'sylius.ui.enabled', + 'sortable' => 'enabled', + 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'], + ], + 'dryRun' => [ + 'type' => 'twig', + 'label' => 'setono_sylius_loyalty.ui.dry_run', + 'sortable' => 'dryRun', + 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'], + ], + ], + 'filters' => [ + 'name' => [ + 'type' => 'string', + 'label' => 'sylius.ui.name', + 'options' => ['fields' => ['name']], + ], + 'enabled' => [ + 'type' => 'boolean', + 'label' => 'sylius.ui.enabled', + ], + ], + 'actions' => [ + 'main' => [ + 'create' => ['type' => 'create'], + ], + 'item' => [ + 'update' => ['type' => 'update'], + 'delete' => ['type' => 'delete'], + ], + ], + ], + 'setono_sylius_loyalty_admin_tier' => [ + 'driver' => [ + 'name' => 'doctrine/orm', + 'options' => ['class' => '%setono_sylius_loyalty.model.tier.class%'], + ], + 'sorting' => ['position' => 'desc'], + 'fields' => [ + 'name' => [ + 'type' => 'string', + 'label' => 'sylius.ui.name', + 'sortable' => 'name', + ], + 'code' => [ + 'type' => 'string', + 'label' => 'sylius.ui.code', + 'sortable' => 'code', + ], + 'channel' => [ + 'type' => 'string', + 'label' => 'sylius.ui.channel', + 'path' => 'channel.code', + ], + 'position' => [ + 'type' => 'string', + 'label' => 'sylius.ui.position', + 'sortable' => 'position', + ], + 'threshold' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.form.tier.threshold', + 'sortable' => 'threshold', + ], + 'earningMultiplier' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.form.tier.earning_multiplier', + ], + 'enabled' => [ + 'type' => 'twig', + 'label' => 'sylius.ui.enabled', + 'sortable' => 'enabled', + 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'], + ], + ], + 'actions' => [ + 'main' => [ + 'create' => ['type' => 'create'], + ], + 'item' => [ + 'update' => ['type' => 'update'], + 'delete' => ['type' => 'delete'], + ], + ], + ], + 'setono_sylius_loyalty_admin_referral' => [ + 'driver' => [ + 'name' => 'doctrine/orm', + 'options' => ['class' => '%setono_sylius_loyalty.model.referral.class%'], + ], + 'sorting' => ['createdAt' => 'desc'], + 'fields' => [ + 'referrer' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.referrer', + 'path' => 'referrerAccount.customer.email', + ], + 'referee' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.referee', + 'path' => 'refereeCustomer.email', + ], + 'code' => [ + 'type' => 'string', + 'label' => 'sylius.ui.code', + ], + 'status' => [ + 'type' => 'string', + 'label' => 'sylius.ui.status', + 'sortable' => 'status', + ], + 'createdAt' => [ + 'type' => 'datetime', + 'label' => 'sylius.ui.date', + 'sortable' => 'createdAt', + ], + 'override' => [ + 'type' => 'twig', + 'label' => 'sylius.ui.actions', + 'path' => '.', + 'options' => ['template' => '@SetonoSyliusLoyaltyPlugin/admin/referral/_override.html.twig'], + ], + ], + 'filters' => [ + 'status' => [ + 'type' => 'select', + 'label' => 'sylius.ui.status', + 'form_options' => [ + 'choices' => [ + 'setono_sylius_loyalty.ui.referral_status.pending' => 'pending', + 'setono_sylius_loyalty.ui.referral_status.qualified' => 'qualified', + 'setono_sylius_loyalty.ui.referral_status.rewarded' => 'rewarded', + 'setono_sylius_loyalty.ui.referral_status.rejected' => 'rejected', + 'setono_sylius_loyalty.ui.referral_status.expired' => 'expired', + ], + ], + ], + ], + ], + 'setono_sylius_loyalty_admin_dry_run_result' => [ + 'driver' => [ + 'name' => 'doctrine/orm', + 'options' => ['class' => '%setono_sylius_loyalty.model.dry_run_result.class%'], + ], + 'sorting' => ['createdAt' => 'desc'], + 'fields' => [ + 'rule' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.rule', + 'path' => 'rule.name', + ], + 'account' => [ + 'type' => 'string', + 'label' => 'sylius.ui.customer', + 'path' => 'account.customer.email', + ], + 'points' => [ + 'type' => 'string', + 'label' => 'setono_sylius_loyalty.ui.points', + 'sortable' => 'points', + ], + 'createdAt' => [ + 'type' => 'datetime', + 'label' => 'sylius.ui.date', + 'sortable' => 'createdAt', + ], + ], + ], + ], + ]); + } + + /** + * Registers the plugin's template-event blocks. + */ + private function prependSyliusUiConfig(ContainerBuilder $container): void + { + if (!$container->hasExtension('sylius_ui')) { + return; + } + + $container->prependExtensionConfig('sylius_ui', [ + 'events' => [ + 'sylius.shop.cart.summary' => [ + 'blocks' => [ + 'setono_sylius_loyalty_cart_earn_hint' => [ + 'template' => '@SetonoSyliusLoyaltyPlugin/shop/cart/_earn_hint.html.twig', + 'priority' => 3, + ], + 'setono_sylius_loyalty_redemption' => [ + 'template' => '@SetonoSyliusLoyaltyPlugin/shop/cart/_redemption.html.twig', + 'priority' => 5, + ], + ], + ], + 'sylius.shop.checkout.complete.summary' => [ + 'blocks' => [ + 'setono_sylius_loyalty_redemption_summary' => [ + 'template' => '@SetonoSyliusLoyaltyPlugin/shop/checkout/_redemption_summary.html.twig', + 'priority' => 5, + ], + ], + ], + 'sylius.shop.product.show.add_to_cart_form' => [ + 'blocks' => [ + // Deviation from the spec's "directly below the add-to-cart button": + // Sylius 1.14 has no template event there, so the hint renders just + // above the button instead + 'setono_sylius_loyalty_earn_hint' => [ + 'template' => '@SetonoSyliusLoyaltyPlugin/shop/product/_earn_hint.html.twig', + 'priority' => -5, + ], + ], + ], + 'sylius.admin.customer.show.content' => [ + 'blocks' => [ + 'setono_sylius_loyalty_customer_loyalty' => [ + 'template' => '@SetonoSyliusLoyaltyPlugin/admin/customer/_loyalty.html.twig', + 'priority' => -10, + ], + ], + ], + ], + ]); + } + public function load(array $configs, ContainerBuilder $container): void { /** - * @psalm-suppress PossiblyNullArgument - * - * @var array{option: scalar} $config + * @var array{ + * resources: array, + * manual_adjustment_reasons: list, + * triggers: list, + * expression_editor: array{cdn_base_url: string}, + * referral: array{query_parameter: string, registration_ip_check: bool, ip_hash_salt: string, reward_cap: int}, + * retain_anonymized_ledger: bool, + * } $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $container->setParameter('setono_sylius_loyalty.option', $config['option']); + $container->setParameter('setono_sylius_loyalty.manual_adjustment_reasons', $config['manual_adjustment_reasons']); + $container->setParameter('setono_sylius_loyalty.triggers', $config['triggers']); + $container->setParameter('setono_sylius_loyalty.expression_editor.cdn_base_url', $config['expression_editor']['cdn_base_url']); + $container->setParameter('setono_sylius_loyalty.retain_anonymized_ledger', $config['retain_anonymized_ledger']); + $container->setParameter('setono_sylius_loyalty.referral.query_parameter', $config['referral']['query_parameter']); + $container->setParameter('setono_sylius_loyalty.referral.registration_ip_check', $config['referral']['registration_ip_check']); + $container->setParameter('setono_sylius_loyalty.referral.ip_hash_salt', $config['referral']['ip_hash_salt']); + $container->setParameter('setono_sylius_loyalty.referral.reward_cap', $config['referral']['reward_cap']); + + $this->registerResources( + 'setono_sylius_loyalty', + SyliusResourceBundle::DRIVER_DOCTRINE_ORM, + $config['resources'], + $container, + ); $loader->load('services.xml'); + + $container->registerForAutoconfiguration(ConditionCheckerInterface::class) + ->addTag('setono_sylius_loyalty.earning_condition'); + $container->registerForAutoconfiguration(AmountCalculatorInterface::class) + ->addTag('setono_sylius_loyalty.earning_amount'); + $container->registerForAutoconfiguration(ExpressionFunctionInterface::class) + ->addTag('setono_sylius_loyalty.expression_function'); + $container->registerForAutoconfiguration(TierQualificationBasisInterface::class) + ->addTag('setono_sylius_loyalty.tier_qualification_basis'); + $container->registerForAutoconfiguration(ReferralFraudCheckInterface::class) + ->addTag('setono_sylius_loyalty.referral_fraud_check'); + } + + /** + * Registers the plugin's state machine callbacks on the winzou engine (still Sylius' + * default graph engine). The symfony/workflow counterparts are plain event listener tags; + * database-level idempotency makes registering both engines safe. + */ + private function prependWinzouStateMachineConfig(ContainerBuilder $container): void + { + if (!$container->hasExtension('winzou_state_machine')) { + return; + } + + $container->prependExtensionConfig('winzou_state_machine', [ + 'sylius_product_review' => [ + 'callbacks' => [ + 'after' => [ + 'setono_sylius_loyalty_dispatch_review_trigger' => [ + 'on' => ['accept'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\DispatchProductReviewApprovedTrigger', 'dispatch'], + 'args' => ['object'], + ], + ], + ], + ], + 'sylius_order_payment' => [ + 'callbacks' => [ + 'after' => [ + 'setono_sylius_loyalty_award_order_points' => [ + 'on' => ['pay'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\AwardOrderPointsListener', 'onOrderPaid'], + 'args' => ['object'], + ], + 'setono_sylius_loyalty_clawback_on_refund' => [ + 'on' => ['refund'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\ClawbackListener', 'clawback'], + 'args' => ['object'], + ], + ], + ], + ], + 'sylius_order' => [ + 'callbacks' => [ + 'after' => [ + 'setono_sylius_loyalty_award_order_points_fulfilled' => [ + 'on' => ['fulfill'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\AwardOrderPointsListener', 'onOrderFulfilled'], + 'args' => ['object'], + ], + 'setono_sylius_loyalty_rollback_redemption' => [ + 'on' => ['cancel'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\RollbackRedemptionListener', 'rollback'], + 'args' => ['object'], + ], + 'setono_sylius_loyalty_clawback_on_cancel' => [ + 'on' => ['cancel'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\ClawbackListener', 'clawback'], + 'args' => ['object'], + ], + ], + ], + ], + 'sylius_order_checkout' => [ + 'callbacks' => [ + // A "before" callback so a failed debit aborts the completion + 'before' => [ + 'setono_sylius_loyalty_redeem_points' => [ + 'on' => ['complete'], + 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\RedeemPointsListener', 'redeem'], + 'args' => ['object'], + ], + ], + ], + ], + ]); } } diff --git a/src/EarningRule/ActionTriggerProcessor.php b/src/EarningRule/ActionTriggerProcessor.php new file mode 100644 index 0000000..008e1eb --- /dev/null +++ b/src/EarningRule/ActionTriggerProcessor.php @@ -0,0 +1,104 @@ +channelResolver->resolve($event); + if (null === $channel) { + // An expected data situation (e.g. a new customer in a multi-channel shop), not a bug + $this->logger->warning(sprintf( + '[Loyalty] No channel could be resolved for the "%s" trigger (customer: %s); nothing was awarded', + $event::getCode(), + (string) $event->getCustomer()->getEmail(), + )); + + return null; + } + + $rules = $this->ruleRepository->findForEvaluation($channel, $event::getCode()); + if ([] === $rules) { + return null; + } + + $account = $this->accountProvider->getByCustomerAndChannel($event->getCustomer(), $channel); + $program = $this->programProvider->getByChannel($channel); + + $context = new EarningContext( + channel: $channel, + customer: $event->getCustomer(), + account: $account, + context: $this->contextVariables($event), + ); + + $result = $this->evaluator->evaluate($rules, $context, $program); + + $this->dryRunLogger->log($result, $account); + + if ($result->points <= 0) { + return null; + } + + return $this->ledger->earnAction( + $account, + $result->points, + $event->getSourceIdentifier(), + $result->rulesBreakdown, + self::expiresAt($program), + ); + } + + /** + * The subclass's own public properties are the trigger's typed expression context. + * + * @return array + */ + private function contextVariables(EarningTriggerEvent $event): array + { + $variables = []; + foreach ((new \ReflectionClass($event))->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if (EarningTriggerEvent::class === $property->getDeclaringClass()->getName()) { + continue; + } + + /** @var mixed $value */ + $value = $property->getValue($event); + $variables[$property->getName()] = $value; + } + + return $variables; + } + + private static function expiresAt(LoyaltyProgramInterface $program): ?\DateTimeImmutable + { + $days = $program->getPointsExpiryDays(); + + return null === $days ? null : new \DateTimeImmutable(sprintf('+%d days', $days)); + } +} diff --git a/src/EarningRule/ActionTriggerProcessorInterface.php b/src/EarningRule/ActionTriggerProcessorInterface.php new file mode 100644 index 0000000..de27673 --- /dev/null +++ b/src/EarningRule/ActionTriggerProcessorInterface.php @@ -0,0 +1,19 @@ + $configuration + */ + public function calculate(array $configuration, AmountCalculationInput $input): float; +} diff --git a/src/EarningRule/Amount/AmountCalculatorRegistry.php b/src/EarningRule/Amount/AmountCalculatorRegistry.php new file mode 100644 index 0000000..d626950 --- /dev/null +++ b/src/EarningRule/Amount/AmountCalculatorRegistry.php @@ -0,0 +1,29 @@ + + */ +final class AmountCalculatorRegistry extends CompositeService implements AmountCalculatorRegistryInterface +{ + public function get(string $type): ?AmountCalculatorInterface + { + foreach ($this->services as $calculator) { + if ($calculator->getType() === $type) { + return $calculator; + } + } + + return null; + } + + public function all(): array + { + return $this->services; + } +} diff --git a/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php b/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php new file mode 100644 index 0000000..3f4a9ef --- /dev/null +++ b/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php @@ -0,0 +1,15 @@ + + */ + public function all(): array; +} diff --git a/src/EarningRule/Amount/ExpressionAmountCalculator.php b/src/EarningRule/Amount/ExpressionAmountCalculator.php new file mode 100644 index 0000000..6e8dcac --- /dev/null +++ b/src/EarningRule/Amount/ExpressionAmountCalculator.php @@ -0,0 +1,61 @@ + 50000 ? floor(basis / 50) : floor(basis / 100)". The "basis" variable holds the + * rule's claimed basis. + */ +final class ExpressionAmountCalculator implements AmountCalculatorInterface +{ + public const TYPE = 'expression'; + + public function __construct( + private readonly ExpressionEvaluatorInterface $expressionEvaluator, + private readonly LoggerInterface $logger, + ) { + } + + public function getType(): string + { + return self::TYPE; + } + + public function getLabel(): string + { + return 'setono_sylius_loyalty.form.earning_rule.amount.expression'; + } + + public function getConfigurationFormType(): ?string + { + return null; // set when the admin forms ship + } + + public function calculate(array $configuration, AmountCalculationInput $input): float + { + $expression = $configuration['expression'] ?? null; + if (!is_string($expression) || '' === $expression) { + return 0.0; + } + + $result = $this->expressionEvaluator->evaluate($expression, $input->context, $input->basisAmount); + + if (!is_int($result) && !is_float($result)) { + $this->logger->warning(sprintf( + '[Loyalty] The amount expression "%s" evaluated to a non-numeric value (%s); the rule contributes nothing', + $expression, + get_debug_type($result), + )); + + return 0.0; + } + + return (float) $result; + } +} diff --git a/src/EarningRule/Amount/FixedAmountCalculator.php b/src/EarningRule/Amount/FixedAmountCalculator.php new file mode 100644 index 0000000..5cefe3c --- /dev/null +++ b/src/EarningRule/Amount/FixedAmountCalculator.php @@ -0,0 +1,39 @@ +units; + } +} diff --git a/src/EarningRule/Amount/MultiplierAmountCalculator.php b/src/EarningRule/Amount/MultiplierAmountCalculator.php new file mode 100644 index 0000000..c3060d0 --- /dev/null +++ b/src/EarningRule/Amount/MultiplierAmountCalculator.php @@ -0,0 +1,48 @@ + $configuration + */ + public static function factor(array $configuration): float + { + $factor = $configuration['factor'] ?? null; + if (!is_int($factor) && !is_float($factor)) { + return 1.0; + } + + return (float) $factor; + } +} diff --git a/src/EarningRule/Amount/PerAmountCalculator.php b/src/EarningRule/Amount/PerAmountCalculator.php new file mode 100644 index 0000000..2c583c0 --- /dev/null +++ b/src/EarningRule/Amount/PerAmountCalculator.php @@ -0,0 +1,40 @@ +basisAmount / $perAmount * (float) $points; + } +} diff --git a/src/EarningRule/Basis/EligibleBasis.php b/src/EarningRule/Basis/EligibleBasis.php new file mode 100644 index 0000000..ea886ae --- /dev/null +++ b/src/EarningRule/Basis/EligibleBasis.php @@ -0,0 +1,27 @@ + $itemAmounts order item id => eligible amount in minor units + */ + public function __construct( + public readonly array $itemAmounts, + public readonly int $extraAmount = 0, + ) { + } + + public function getTotal(): int + { + return (int) array_sum($this->itemAmounts) + $this->extraAmount; + } +} diff --git a/src/EarningRule/Basis/EligibleBasisCalculator.php b/src/EarningRule/Basis/EligibleBasisCalculator.php new file mode 100644 index 0000000..56a448b --- /dev/null +++ b/src/EarningRule/Basis/EligibleBasisCalculator.php @@ -0,0 +1,48 @@ +isIncludeTaxes(); + + $itemAmounts = []; + $itemsSum = 0; + foreach ($order->getItems() as $item) { + if (!$item instanceof OrderItemInterface || null === $item->getId()) { + continue; + } + + // getTotal() is the discounted amount: unit prices, distributed promotions, the + // distributed loyalty redemption, and non-neutral taxes; getTaxTotal() covers both + // included (neutral) and added (non-neutral) taxes + $amount = $item->getTotal(); + if (!$includeTaxes) { + $amount -= $item->getTaxTotal(); + } + + $amount = max(0, $amount); + $itemAmounts[(int) $item->getId()] = $amount; + $itemsSum += $amount; + } + + if (LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL === $program->getEarningBasis()) { + return new EligibleBasis($itemAmounts); + } + + $orderTotal = $order->getTotal(); + if (!$includeTaxes) { + $orderTotal -= $order->getTaxTotal(); + } + + return new EligibleBasis($itemAmounts, max(0, $orderTotal - $itemsSum)); + } +} diff --git a/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php b/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php new file mode 100644 index 0000000..82eadfb --- /dev/null +++ b/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php @@ -0,0 +1,19 @@ +order) { + return false; + } + + /** @var list $taxons */ + $taxons = array_values(array_filter((array) ($configuration['taxons'] ?? []), is_string(...))); + if ([] === $taxons) { + return false; + } + + foreach ($context->order->getItems() as $item) { + if (!$item instanceof OrderItemInterface) { + continue; + } + + $product = $item->getProduct(); + if (!$product instanceof ProductInterface) { + continue; + } + + foreach ($product->getTaxons() as $taxon) { + if (in_array($taxon->getCode(), $taxons, true)) { + return true; + } + } + } + + return false; + } + + public function requiresCustomer(): bool + { + return false; + } + + public function requiresCart(): bool + { + return true; + } +} diff --git a/src/EarningRule/Checker/ConditionCheckerInterface.php b/src/EarningRule/Checker/ConditionCheckerInterface.php new file mode 100644 index 0000000..4274563 --- /dev/null +++ b/src/EarningRule/Checker/ConditionCheckerInterface.php @@ -0,0 +1,46 @@ + $configuration + */ + public function check(array $configuration, EarningContext $context): bool; + + /** + * Whether this condition needs a customer. The earn-hint pipeline excludes rules whose + * conditions need what the hint context lacks — hints may understate, never overstate. + */ + public function requiresCustomer(): bool; + + /** + * Whether this condition needs the real cart (product-page hints evaluate a synthetic + * one-item cart). + */ + public function requiresCart(): bool; +} diff --git a/src/EarningRule/Checker/ConditionCheckerRegistry.php b/src/EarningRule/Checker/ConditionCheckerRegistry.php new file mode 100644 index 0000000..8b538f8 --- /dev/null +++ b/src/EarningRule/Checker/ConditionCheckerRegistry.php @@ -0,0 +1,29 @@ + + */ +final class ConditionCheckerRegistry extends CompositeService implements ConditionCheckerRegistryInterface +{ + public function get(string $type): ?ConditionCheckerInterface + { + foreach ($this->services as $checker) { + if ($checker->getType() === $type) { + return $checker; + } + } + + return null; + } + + public function all(): array + { + return $this->services; + } +} diff --git a/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php b/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php new file mode 100644 index 0000000..34d8759 --- /dev/null +++ b/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php @@ -0,0 +1,15 @@ + + */ + public function all(): array; +} diff --git a/src/EarningRule/Checker/CustomerGroupConditionChecker.php b/src/EarningRule/Checker/CustomerGroupConditionChecker.php new file mode 100644 index 0000000..d12f4e9 --- /dev/null +++ b/src/EarningRule/Checker/CustomerGroupConditionChecker.php @@ -0,0 +1,50 @@ +customer?->getGroup(); + if (null === $group) { + return false; + } + + /** @var list $groups */ + $groups = array_values(array_filter((array) ($configuration['groups'] ?? []), is_string(...))); + + return in_array($group->getCode(), $groups, true); + } + + public function requiresCustomer(): bool + { + return true; + } + + public function requiresCart(): bool + { + return false; + } +} diff --git a/src/EarningRule/Checker/DateWindowConditionChecker.php b/src/EarningRule/Checker/DateWindowConditionChecker.php new file mode 100644 index 0000000..3380c3b --- /dev/null +++ b/src/EarningRule/Checker/DateWindowConditionChecker.php @@ -0,0 +1,71 @@ +getNow(); + + $from = self::parseDate($configuration['from'] ?? null); + if (null !== $from && $now < $from) { + return false; + } + + $until = self::parseDate($configuration['until'] ?? null); + if (null !== $until && $now > $until) { + return false; + } + + return true; + } + + private static function parseDate(mixed $value): ?\DateTimeImmutable + { + if (!is_string($value) || '' === $value) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + return null; + } + } + + public function requiresCustomer(): bool + { + return false; + } + + public function requiresCart(): bool + { + return false; + } +} diff --git a/src/EarningRule/Checker/DayOfWeekConditionChecker.php b/src/EarningRule/Checker/DayOfWeekConditionChecker.php new file mode 100644 index 0000000..877b9c4 --- /dev/null +++ b/src/EarningRule/Checker/DayOfWeekConditionChecker.php @@ -0,0 +1,51 @@ + is_numeric($day) ? (int) $day : 0, + (array) ($configuration['days'] ?? []), + ); + + return in_array((int) $context->getNow()->format('N'), $days, true); + } + + public function requiresCustomer(): bool + { + return false; + } + + public function requiresCart(): bool + { + return false; + } +} diff --git a/src/EarningRule/Checker/ExpressionConditionChecker.php b/src/EarningRule/Checker/ExpressionConditionChecker.php new file mode 100644 index 0000000..6098fb5 --- /dev/null +++ b/src/EarningRule/Checker/ExpressionConditionChecker.php @@ -0,0 +1,56 @@ +expressionEvaluator->evaluate($expression, $context); + } + + public function requiresCustomer(): bool + { + return true; + } + + public function requiresCart(): bool + { + return true; + } +} diff --git a/src/EarningRule/Checker/NthOrderConditionChecker.php b/src/EarningRule/Checker/NthOrderConditionChecker.php new file mode 100644 index 0000000..2bd8512 --- /dev/null +++ b/src/EarningRule/Checker/NthOrderConditionChecker.php @@ -0,0 +1,103 @@ +order || null === $context->customer) { + return false; + } + + $nth = $configuration['nth'] ?? null; + if (!is_int($nth) || $nth < 1) { + return false; + } + + $position = $this->position($context); + if (0 === $position) { + return false; + } + + if (true === ($configuration['every'] ?? false)) { + return 0 === $position % $nth; + } + + return $position === $nth; + } + + /** + * The 1-based position of the evaluated order among the customer's paid orders in the + * channel. When the evaluated order is not itself counted as paid yet (e.g. in the rule + * tester before payment), it is counted on top. + */ + private function position(EarningContext $context): int + { + $paidOrders = (int) $this->entityManager->createQueryBuilder() + ->select('COUNT(o.id)') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.channel = :channel') + ->andWhere('o.paymentState = :paymentState') + ->setParameter('customer', $context->customer) + ->setParameter('channel', $context->channel) + ->setParameter('paymentState', OrderPaymentStates::STATE_PAID) + ->getQuery() + ->getSingleScalarResult() + ; + + $order = $context->order; + if (null !== $order && OrderPaymentStates::STATE_PAID !== $order->getPaymentState()) { + ++$paidOrders; + } + + return $paidOrders; + } + + public function requiresCustomer(): bool + { + return true; + } + + public function requiresCart(): bool + { + return false; + } +} diff --git a/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php b/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php new file mode 100644 index 0000000..cdc28be --- /dev/null +++ b/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php @@ -0,0 +1,51 @@ +order) { + return false; + } + + $amount = $configuration['amount'] ?? null; + if (!is_int($amount)) { + return false; + } + + return $context->order->getTotal() >= $amount; + } + + public function requiresCustomer(): bool + { + return false; + } + + public function requiresCart(): bool + { + return true; + } +} diff --git a/src/EarningRule/DryRunLogger.php b/src/EarningRule/DryRunLogger.php new file mode 100644 index 0000000..0afcf99 --- /dev/null +++ b/src/EarningRule/DryRunLogger.php @@ -0,0 +1,66 @@ + $dryRunResultFactory + */ + public function __construct( + private readonly FactoryInterface $dryRunResultFactory, + ManagerRegistry $managerRegistry, + ) { + $this->managerRegistry = $managerRegistry; + } + + public function log( + EvaluationResult $result, + ?LoyaltyAccountInterface $account = null, + ?OrderInterface $order = null, + ): void { + if ([] === $result->dryRunEvaluations) { + return; + } + + $lastPersisted = null; + foreach ($result->dryRunEvaluations as $evaluation) { + if (!$evaluation->matched) { + continue; + } + + $dryRunResult = $this->dryRunResultFactory->createNew(); + Assert::isInstanceOf($dryRunResult, DryRunResultInterface::class); + + $dryRunResult->setRule($evaluation->rule); + $dryRunResult->setAccount($account); + $dryRunResult->setOrder($order); + $dryRunResult->setPoints((int) round($evaluation->points)); + $dryRunResult->setDetails([ + 'claimedBasis' => $evaluation->claimedBasis, + 'claimedUnits' => $evaluation->claimedUnits, + 'factor' => $evaluation->factor, + 'failedConditions' => $evaluation->failedConditions, + ]); + + $this->getManager($dryRunResult)->persist($dryRunResult); + $lastPersisted = $dryRunResult; + } + + if (null !== $lastPersisted) { + $this->getManager($lastPersisted)->flush(); + } + } +} diff --git a/src/EarningRule/DryRunLoggerInterface.php b/src/EarningRule/DryRunLoggerInterface.php new file mode 100644 index 0000000..8360b9b --- /dev/null +++ b/src/EarningRule/DryRunLoggerInterface.php @@ -0,0 +1,21 @@ + $itemAmounts order item id => eligible amount in minor units of + * the channel base currency (discounted, redemption excluded, per the program's + * earning basis) + * @param array $context typed context variables of the trigger event + * @param int $extraAmount non-item basis (e.g. shipping under the order_total earning + * basis); only ever feeds order-scoped rules + */ + public function __construct( + public readonly ChannelInterface $channel, + public readonly ?CustomerInterface $customer = null, + public readonly ?LoyaltyAccountInterface $account = null, + public readonly ?OrderInterface $order = null, + public readonly array $itemAmounts = [], + public readonly array $context = [], + private readonly ?\DateTimeImmutable $now = null, + public readonly int $extraAmount = 0, + ) { + } + + /** + * The evaluation time — overridable so the rule tester can preview scheduled rules. + */ + public function getNow(): \DateTimeImmutable + { + return $this->now ?? new \DateTimeImmutable(); + } + + /** + * The total eligible basis in minor units. + */ + public function getBasis(): int + { + return (int) array_sum($this->itemAmounts) + $this->extraAmount; + } +} diff --git a/src/EarningRule/EarningRuleEvaluator.php b/src/EarningRule/EarningRuleEvaluator.php new file mode 100644 index 0000000..8c07be2 --- /dev/null +++ b/src/EarningRule/EarningRuleEvaluator.php @@ -0,0 +1,440 @@ +isEnabled() && $this->isInWindow($rule, $context)) { + $active[] = $rule; + } + } + + $live = array_values(array_filter($active, static fn (EarningRuleInterface $rule): bool => !$rule->isDryRun())); + + [$total, $evaluations] = $this->run($live, $context); + + // Each dry-run rule is evaluated as if it were live — alongside the actual live rules, + // so claiming and stacking behave exactly as they would — but its effect never reaches + // the live result. + $dryRunEvaluations = []; + foreach ($active as $rule) { + if (!$rule->isDryRun()) { + continue; + } + + [$dryTotal, $dryEvaluations] = $this->run([...$live, $rule], $context); + + foreach ($dryEvaluations as $evaluation) { + if ($evaluation->rule === $rule) { + // For multiplier rules the contribution is the delta they cause on the total + if (null !== $evaluation->factor && $evaluation->applied) { + $evaluation = new RuleEvaluation( + $rule, + $evaluation->matched, + $evaluation->failedConditions, + $evaluation->claimedItems, + $evaluation->claimedBasis, + $evaluation->claimedUnits, + $dryTotal - $total, + $evaluation->factor, + $evaluation->applied, + ); + } + + $dryRunEvaluations[] = $evaluation; + } + } + } + + // The tier's earning multiplier applies after all rules, immediately before rounding + $tierMultiplier = $context->account?->getTier()?->getEarningMultiplier() ?? 1.0; + + return new EvaluationResult( + $this->round($total * $tierMultiplier, $program), + $this->buildBreakdown($evaluations), + $evaluations, + $dryRunEvaluations, + ); + } + + /** + * Runs a full evaluation of the given rules and returns the unrounded total and the + * per-rule detail. + * + * @param list $rules + * + * @return array{0: float, 1: list} + */ + private function run(array $rules, EarningContext $context): array + { + $matched = []; + $failedConditions = []; + foreach ($rules as $key => $rule) { + $failedConditions[$key] = $this->failedConditions($rule, $context); + $matched[$key] = $this->conditionsPass($rule, $failedConditions[$key]); + } + + $baseRules = []; + $multiplierRules = []; + foreach ($rules as $key => $rule) { + if (MultiplierAmountCalculator::TYPE === $rule->getAmountType()) { + $multiplierRules[$key] = $rule; + } else { + $baseRules[$key] = $rule; + } + } + + // Claiming: every item is claimed at the most specific matching scope; the competitor + // set at that scope is resolved by stackability. Unclaimed items form the remainder of + // the order-scoped rules. + /** @var array> $claimedItems rule key => item id => amount */ + $claimedItems = []; + + /** @var array $claimedUnits rule key => units */ + $claimedUnits = []; + + $remainderBasis = $context->extraAmount; + + $items = $this->itemsById($context); + foreach ($context->itemAmounts as $itemId => $amount) { + $item = $items[$itemId] ?? null; + + $competitors = $this->itemCompetitors($baseRules, $matched, $item); + if ([] === $competitors) { + $remainderBasis += $amount; + + continue; + } + + foreach ($this->resolveStacking($competitors) as $key => $rule) { + $claimedItems[$key][$itemId] = $amount; + $claimedUnits[$key] = ($claimedUnits[$key] ?? 0) + ($item?->getQuantity() ?? 1); + } + } + + // Order-scoped rules compete for the remainder (or for the whole basis in orderless + // action-trigger contexts) + $orderScopedMatched = array_filter( + $baseRules, + static fn (EarningRuleInterface $rule, int $key): bool => $matched[$key] && EarningRuleInterface::SCOPE_ORDER === $rule->getScope(), + \ARRAY_FILTER_USE_BOTH, + ); + + $orderScopedApplied = $this->resolveStacking($orderScopedMatched); + + // Base points + $baseTotal = 0.0; + $points = []; + $applied = []; + foreach ($baseRules as $key => $rule) { + $isOrderScoped = EarningRuleInterface::SCOPE_ORDER === $rule->getScope(); + $applied[$key] = $matched[$key] && ($isOrderScoped ? isset($orderScopedApplied[$key]) : isset($claimedItems[$key])); + + if (!$applied[$key]) { + $points[$key] = 0.0; + + continue; + } + + $input = new AmountCalculationInput( + $isOrderScoped ? $remainderBasis : (int) array_sum($claimedItems[$key] ?? []), + $isOrderScoped ? 1 : ($claimedUnits[$key] ?? 1), + $context, + ); + + $points[$key] = $this->calculate($rule, $input); + $baseTotal += $points[$key]; + } + + // Multipliers: stackable ones multiply cumulatively; any non-stackable wins alone + $matchedMultipliers = array_filter( + $multiplierRules, + static fn (int $key): bool => $matched[$key], + \ARRAY_FILTER_USE_KEY, + ); + + $appliedMultipliers = $this->resolveStacking($matchedMultipliers); + + $factor = 1.0; + $factors = []; + foreach ($multiplierRules as $key => $rule) { + $applied[$key] = isset($appliedMultipliers[$key]); + $factors[$key] = MultiplierAmountCalculator::factor($rule->getAmountConfiguration()); + if ($applied[$key]) { + $factor *= $factors[$key]; + } + } + + $evaluations = []; + foreach ($rules as $key => $rule) { + $isMultiplier = MultiplierAmountCalculator::TYPE === $rule->getAmountType(); + + $evaluations[] = new RuleEvaluation( + $rule, + $matched[$key], + $failedConditions[$key], + $claimedItems[$key] ?? [], + EarningRuleInterface::SCOPE_ORDER === $rule->getScope() ? $remainderBasis : (int) array_sum($claimedItems[$key] ?? []), + $claimedUnits[$key] ?? 0, + $points[$key] ?? 0.0, + $isMultiplier ? $factors[$key] : null, + $applied[$key] ?? false, + ); + } + + return [$baseTotal * $factor, $evaluations]; + } + + /** + * The matched base rules competing for the given item, taken from the most specific + * matching scope (product > taxon). + * + * @param array $baseRules + * @param array $matched + * + * @return array + */ + private function itemCompetitors(array $baseRules, array $matched, ?OrderItemInterface $item): array + { + if (null === $item) { + return []; + } + + foreach ([EarningRuleInterface::SCOPE_PRODUCT, EarningRuleInterface::SCOPE_TAXON] as $scope) { + $competitors = array_filter( + $baseRules, + fn (EarningRuleInterface $rule, int $key): bool => $matched[$key] && + $rule->getScope() === $scope && + $this->scopeMatchesItem($rule, $item), + \ARRAY_FILTER_USE_BOTH, + ); + + if ([] !== $competitors) { + return $competitors; + } + } + + return []; + } + + private function scopeMatchesItem(EarningRuleInterface $rule, OrderItemInterface $item): bool + { + $product = $item->getProduct(); + if (!$product instanceof ProductInterface) { + return false; + } + + $configuration = $rule->getScopeConfiguration(); + + if (EarningRuleInterface::SCOPE_PRODUCT === $rule->getScope()) { + /** @var list $products */ + $products = array_values(array_filter((array) ($configuration['products'] ?? []), is_string(...))); + + return in_array($product->getCode(), $products, true); + } + + if (EarningRuleInterface::SCOPE_TAXON === $rule->getScope()) { + /** @var list $taxons */ + $taxons = array_values(array_filter((array) ($configuration['taxons'] ?? []), is_string(...))); + + foreach ($product->getTaxons() as $taxon) { + if (in_array($taxon->getCode(), $taxons, true)) { + return true; + } + } + } + + return false; + } + + /** + * All stackable rules apply and sum; if any rule is non-stackable, the highest-priority + * (then lowest id) non-stackable rule applies alone. + * + * @param array $competitors + * + * @return array the applied subset, keyed as the input + */ + private function resolveStacking(array $competitors): array + { + $nonStackable = array_filter( + $competitors, + static fn (EarningRuleInterface $rule): bool => !$rule->isStackable(), + ); + + if ([] === $nonStackable) { + return $competitors; + } + + uasort( + $nonStackable, + static fn (EarningRuleInterface $a, EarningRuleInterface $b): int => [-$a->getPriority(), $a->getId() ?? \PHP_INT_MAX] + <=> [-$b->getPriority(), $b->getId() ?? \PHP_INT_MAX], + ); + + $winnerKey = array_key_first($nonStackable); + + return [$winnerKey => $nonStackable[$winnerKey]]; + } + + /** + * @return list the condition types that failed + */ + private function failedConditions(EarningRuleInterface $rule, EarningContext $context): array + { + $failed = []; + foreach ($rule->getConditions() as $condition) { + $type = $condition->getType(); + if (null === $type) { + continue; + } + + $checker = $this->conditionCheckers->get($type); + if (null === $checker) { + $this->logger->warning(sprintf( + '[Loyalty] Unknown condition type "%s" on earning rule (id: %s); the condition fails', + $type, + (string) ($rule->getId() ?? 'unsaved'), + )); + $failed[] = $type; + + continue; + } + + if (!$checker->check($condition->getConfiguration(), $context)) { + $failed[] = $type; + } + } + + return $failed; + } + + /** + * @param list $failedConditions + */ + private function conditionsPass(EarningRuleInterface $rule, array $failedConditions): bool + { + $conditionCount = $rule->getConditions()->count(); + if (0 === $conditionCount) { + return true; + } + + if (EarningRuleInterface::CONDITIONS_MATCH_ANY === $rule->getConditionsMatch()) { + return count($failedConditions) < $conditionCount; + } + + return [] === $failedConditions; + } + + private function calculate(EarningRuleInterface $rule, AmountCalculationInput $input): float + { + $amountType = $rule->getAmountType(); + if (null === $amountType) { + return 0.0; + } + + $calculator = $this->amountCalculators->get($amountType); + if (null === $calculator) { + $this->logger->warning(sprintf( + '[Loyalty] Unknown amount type "%s" on earning rule (id: %s); the rule contributes nothing', + $amountType, + (string) ($rule->getId() ?? 'unsaved'), + )); + + return 0.0; + } + + return $calculator->calculate($rule->getAmountConfiguration(), $input); + } + + private function isInWindow(EarningRuleInterface $rule, EarningContext $context): bool + { + $now = $context->getNow(); + + $startsAt = $rule->getStartsAt(); + if (null !== $startsAt && $now < $startsAt) { + return false; + } + + $endsAt = $rule->getEndsAt(); + + return null === $endsAt || $now <= $endsAt; + } + + private function round(float $points, LoyaltyProgramInterface $program): int + { + return match ($program->getRounding()) { + LoyaltyProgramInterface::ROUNDING_CEIL => (int) ceil($points), + LoyaltyProgramInterface::ROUNDING_ROUND => (int) round($points), + default => (int) floor($points), + }; + } + + /** + * @return array + */ + private function itemsById(EarningContext $context): array + { + $items = []; + if (null !== $context->order) { + foreach ($context->order->getItems() as $item) { + if ($item instanceof OrderItemInterface && null !== $item->getId()) { + $items[(int) $item->getId()] = $item; + } + } + } + + return $items; + } + + /** + * @param list $evaluations + * + * @return array + */ + private function buildBreakdown(array $evaluations): array + { + $rules = []; + $multipliers = []; + foreach ($evaluations as $evaluation) { + if (!$evaluation->applied) { + continue; + } + + $id = (string) ($evaluation->rule->getId() ?? spl_object_id($evaluation->rule)); + if (null !== $evaluation->factor) { + $multipliers[$id] = $evaluation->factor; + } else { + $rules[$id] = round($evaluation->points, 2); + } + } + + return [ + 'rules' => $rules, + 'multipliers' => $multipliers, + ]; + } +} diff --git a/src/EarningRule/EarningRuleEvaluatorInterface.php b/src/EarningRule/EarningRuleEvaluatorInterface.php new file mode 100644 index 0000000..755d1ed --- /dev/null +++ b/src/EarningRule/EarningRuleEvaluatorInterface.php @@ -0,0 +1,22 @@ + taxon > order specificity, base-rule stacking, + * multiplier rules, and the program's rounding. Read-only — never writes anything. + */ +interface EarningRuleEvaluatorInterface +{ + /** + * @param iterable $rules rules sharing the same trigger; disabled or + * out-of-window rules are ignored, dry-run rules are diverted to the dry-run result + */ + public function evaluate(iterable $rules, EarningContext $context, LoyaltyProgramInterface $program): EvaluationResult; +} diff --git a/src/EarningRule/EvaluationResult.php b/src/EarningRule/EvaluationResult.php new file mode 100644 index 0000000..2114469 --- /dev/null +++ b/src/EarningRule/EvaluationResult.php @@ -0,0 +1,26 @@ + $rulesBreakdown recorded on the earn transaction: rule id => + * points contributed, multipliers noted + * @param list $ruleEvaluations live rules, matched or not + * @param list $dryRunEvaluations what each dry-run rule would have contributed + */ + public function __construct( + public readonly int $points, + public readonly array $rulesBreakdown, + public readonly array $ruleEvaluations, + public readonly array $dryRunEvaluations, + ) { + } +} diff --git a/src/EarningRule/RuleEvaluation.php b/src/EarningRule/RuleEvaluation.php new file mode 100644 index 0000000..9d88aa5 --- /dev/null +++ b/src/EarningRule/RuleEvaluation.php @@ -0,0 +1,34 @@ + $failedConditions condition types that did not pass + * @param array $claimedItems order item id => claimed basis amount (minor units) + * @param float $points the rule's (unrounded) contribution; 0 for multiplier rules + * @param float|null $factor the multiplier factor; null for base rules + * @param bool $applied whether the rule survived the stacking resolution + */ + public function __construct( + public readonly EarningRuleInterface $rule, + public readonly bool $matched, + public readonly array $failedConditions, + public readonly array $claimedItems, + public readonly int $claimedBasis, + public readonly int $claimedUnits, + public readonly float $points, + public readonly ?float $factor, + public readonly bool $applied, + ) { + } +} diff --git a/src/Event/AwardingPoints.php b/src/Event/AwardingPoints.php new file mode 100644 index 0000000..cc04d57 --- /dev/null +++ b/src/Event/AwardingPoints.php @@ -0,0 +1,79 @@ + $rulesBreakdown + */ + public function __construct( + private readonly LoyaltyAccountInterface $account, + private int $points, + private ?\DateTimeImmutable $expiresAt, + private readonly ?OrderInterface $order = null, + private readonly ?string $sourceIdentifier = null, + private readonly array $rulesBreakdown = [], + ) { + } + + public function getAccount(): LoyaltyAccountInterface + { + return $this->account; + } + + public function getPoints(): int + { + return $this->points; + } + + public function setPoints(int $points): void + { + $this->points = $points; + } + + public function getExpiresAt(): ?\DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiresAt(?\DateTimeImmutable $expiresAt): void + { + $this->expiresAt = $expiresAt; + } + + /** + * The order being awarded for. Null for action-trigger earning. + */ + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + /** + * The deduplication identifier. Null for order earning. + */ + public function getSourceIdentifier(): ?string + { + return $this->sourceIdentifier; + } + + /** + * @return array + */ + public function getRulesBreakdown(): array + { + return $this->rulesBreakdown; + } +} diff --git a/src/Event/CancellableTrait.php b/src/Event/CancellableTrait.php new file mode 100644 index 0000000..940d028 --- /dev/null +++ b/src/Event/CancellableTrait.php @@ -0,0 +1,26 @@ +cancelled = true; + } + + public function isCancelled(): bool + { + return $this->cancelled; + } +} diff --git a/src/Event/ClawingBackPoints.php b/src/Event/ClawingBackPoints.php new file mode 100644 index 0000000..adeffe5 --- /dev/null +++ b/src/Event/ClawingBackPoints.php @@ -0,0 +1,51 @@ +account; + } + + public function getPoints(): int + { + return $this->points; + } + + public function setPoints(int $points): void + { + $this->points = $points; + } + + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + public function getEarn(): CreditLoyaltyTransactionInterface + { + return $this->earn; + } +} diff --git a/src/Event/ExpiringPoints.php b/src/Event/ExpiringPoints.php new file mode 100644 index 0000000..68bb946 --- /dev/null +++ b/src/Event/ExpiringPoints.php @@ -0,0 +1,41 @@ +account; + } + + public function getLot(): CreditLoyaltyTransactionInterface + { + return $this->lot; + } + + public function getPoints(): int + { + return $this->points; + } +} diff --git a/src/Event/ManualAdjustment.php b/src/Event/ManualAdjustment.php new file mode 100644 index 0000000..a61a34a --- /dev/null +++ b/src/Event/ManualAdjustment.php @@ -0,0 +1,19 @@ +account; + } + + public function getPoints(): int + { + return $this->points; + } + + public function getOrder(): OrderInterface + { + return $this->order; + } +} diff --git a/src/Event/RedemptionRolledBack.php b/src/Event/RedemptionRolledBack.php new file mode 100644 index 0000000..ac9e720 --- /dev/null +++ b/src/Event/RedemptionRolledBack.php @@ -0,0 +1,19 @@ +customer; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function getSourceIdentifier(): string + { + return $this->sourceIdentifier ?? static::getCode(); + } +} diff --git a/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php b/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php new file mode 100644 index 0000000..9383cf8 --- /dev/null +++ b/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php @@ -0,0 +1,29 @@ +dispatch($order, LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID); + } + + /** + * The winzou callback entry point for the sylius_order graph's "fulfill" transition. + */ + public function onOrderFulfilled(OrderInterface $order): void + { + $this->dispatch($order, LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED); + } + + public function onPaymentWorkflowCompleted(CompletedEvent $event): void + { + $order = $event->getSubject(); + if ($order instanceof OrderInterface) { + $this->onOrderPaid($order); + } + } + + public function onOrderWorkflowCompleted(CompletedEvent $event): void + { + $order = $event->getSubject(); + if ($order instanceof OrderInterface) { + $this->onOrderFulfilled($order); + } + } + + private function dispatch(OrderInterface $order, string $awardMoment): void + { + $channel = $order->getChannel(); + $orderId = $order->getId(); + + if (null === $channel || !is_int($orderId) || null === $order->getCustomer()) { + return; + } + + if ($this->programProvider->getByChannel($channel)->getAwardOrderPointsAt() !== $awardMoment) { + return; + } + + $this->messageBus->dispatch( + new Envelope(new AwardOrderPoints($orderId), [new DispatchAfterCurrentBusStamp()]), + ); + } +} diff --git a/src/EventListener/ClawbackListener.php b/src/EventListener/ClawbackListener.php new file mode 100644 index 0000000..5bca11f --- /dev/null +++ b/src/EventListener/ClawbackListener.php @@ -0,0 +1,70 @@ +transactionRepository->findEarnOrderTransaction($order); + if (null !== $earn && $earn->getPoints() > 0) { + $this->ledger->clawback($order, $earn->getPoints()); + } + + $this->clawbackReferralRewards($order); + } + + /** + * Cancelling or refunding a referral's qualifying order claws back both parties' rewards + * (idempotent per credit via the (type, earn) unique constraint). + */ + private function clawbackReferralRewards(OrderInterface $order): void + { + $referral = $this->referralRepository->findOneBy(['refereeFirstOrder' => $order]); + if (!$referral instanceof ReferralInterface || ReferralInterface::STATUS_REWARDED !== $referral->getStatus()) { + return; + } + + foreach ($this->transactionRepository->findEarnReferralTransactions($referral) as $credit) { + if ($credit->getPoints() > 0) { + $this->ledger->clawbackCredit($credit, $order); + } + } + } + + /** + * The symfony/workflow entry point. + */ + public function onWorkflowCompleted(CompletedEvent $event): void + { + $order = $event->getSubject(); + if ($order instanceof OrderInterface) { + $this->clawback($order); + } + } +} diff --git a/src/EventListener/CreateReferralOnRegistrationListener.php b/src/EventListener/CreateReferralOnRegistrationListener.php new file mode 100644 index 0000000..5252e81 --- /dev/null +++ b/src/EventListener/CreateReferralOnRegistrationListener.php @@ -0,0 +1,96 @@ + $accountRepository + * @param FactoryInterface $referralFactory + */ + public function __construct( + private readonly RequestStack $requestStack, + private readonly RepositoryInterface $accountRepository, + private readonly ReferralRepositoryInterface $referralRepository, + private readonly FactoryInterface $referralFactory, + private readonly ChannelContextInterface $channelContext, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + private readonly bool $registrationIpCheck, + private readonly string $ipHashSalt, + ) { + } + + public function __invoke(GenericEvent $event): void + { + $customer = $event->getSubject(); + if (!$customer instanceof CustomerInterface) { + return; + } + + $request = $this->requestStack->getMainRequest(); + if (null === $request) { + return; + } + + $code = $request->cookies->get(AttributionCookie::NAME); + if (!is_string($code) || !AttributionCookie::isValidFormat($code)) { + return; + } + + $referrerAccount = $this->accountRepository->findOneBy(['referralCode' => $code]); + if (!$referrerAccount instanceof LoyaltyAccountInterface) { + return; + } + + $channel = $this->channelContext->getChannel(); + + // Self-referral through one's own link: silently no referral (the fraud checks also + // guard the reward path) + if ($referrerAccount->getCustomer() === $customer) { + return; + } + + if (null !== $this->referralRepository->findOneByRefereeAndChannel($customer, $channel)) { + return; + } + + $referral = $this->referralFactory->createNew(); + $referral->setReferrerAccount($referrerAccount); + $referral->setRefereeCustomer($customer); + $referral->setChannel($channel); + $referral->setCode($code); + + if ($this->registrationIpCheck && null !== $request->getClientIp()) { + $referral->setRegistrationIpHash(hash('sha256', $this->ipHashSalt . $request->getClientIp())); + } + + $this->entityManager->persist($referral); + $this->entityManager->flush(); + + $this->logger->info(sprintf( + '[Loyalty] Referral created: customer %s referred with code %s', + (string) $customer->getId(), + $code, + )); + } +} diff --git a/src/EventListener/CustomerDeletionListener.php b/src/EventListener/CustomerDeletionListener.php new file mode 100644 index 0000000..d0bddea --- /dev/null +++ b/src/EventListener/CustomerDeletionListener.php @@ -0,0 +1,79 @@ + $accountClass + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly bool $retainAnonymizedLedger, + private readonly string $accountClass, + ) { + } + + public function __invoke(GenericEvent $event): void + { + $customer = $event->getSubject(); + if (!$customer instanceof CustomerInterface) { + return; + } + + /** @var list $accounts */ + $accounts = $this->entityManager->getRepository($this->accountClass)->findBy(['customer' => $customer]); + + foreach ($accounts as $account) { + if ($this->retainAnonymizedLedger) { + $this->anonymize($account); + } else { + $this->entityManager->remove($account); + } + } + } + + private function anonymize(LoyaltyAccountInterface $account): void + { + $account->setCustomer(null); + $account->setAnonymizedToken(bin2hex(random_bytes(16))); + $account->setReferralCode(null); + $account->setEnabled(false); + + foreach ($this->transactionRepository->findForReplay($account) as $transaction) { + if ($transaction instanceof EarnOrderLoyaltyTransactionInterface || $transaction instanceof RedeemLoyaltyTransactionInterface) { + $transaction->setOrder(null); + } + + if ($transaction instanceof EarnActionLoyaltyTransactionInterface) { + $transaction->setSourceIdentifier(null); + } + + if ($transaction instanceof ManualLoyaltyTransactionInterface) { + $transaction->setNote(null); + $transaction->setAdminUser(null); + } + } + } +} diff --git a/src/EventListener/DispatchClaimPastOrderPointsListener.php b/src/EventListener/DispatchClaimPastOrderPointsListener.php new file mode 100644 index 0000000..591a70a --- /dev/null +++ b/src/EventListener/DispatchClaimPastOrderPointsListener.php @@ -0,0 +1,52 @@ +getSubject(); + if (!$customer instanceof CustomerInterface || null === $customer->getId()) { + return; + } + + try { + $channel = $this->channelContext->getChannel(); + } catch (ChannelNotFoundException) { + return; + } + + if (null === $channel->getId() || !$this->programProvider->getByChannel($channel)->isRetroactiveGuestPoints()) { + return; + } + + $this->messageBus->dispatch(new Envelope( + new ClaimPastOrderPoints((int) $customer->getId(), (int) $channel->getId()), + [new DispatchAfterCurrentBusStamp()], + )); + } +} diff --git a/src/EventListener/DispatchCustomerRegisteredTrigger.php b/src/EventListener/DispatchCustomerRegisteredTrigger.php new file mode 100644 index 0000000..538d01c --- /dev/null +++ b/src/EventListener/DispatchCustomerRegisteredTrigger.php @@ -0,0 +1,32 @@ +getSubject(); + if (!$customer instanceof CustomerInterface) { + return; + } + + $this->eventDispatcher->dispatch(new CustomerRegisteredTriggerEvent($customer)); + } +} diff --git a/src/EventListener/DispatchProductReviewApprovedTrigger.php b/src/EventListener/DispatchProductReviewApprovedTrigger.php new file mode 100644 index 0000000..8a1712f --- /dev/null +++ b/src/EventListener/DispatchProductReviewApprovedTrigger.php @@ -0,0 +1,51 @@ +") makes double execution a no-op. + */ +final class DispatchProductReviewApprovedTrigger +{ + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) { + } + + /** + * The winzou callback entry point. + */ + public function dispatch(ReviewInterface $review): void + { + $customer = $review->getAuthor(); + $reviewId = $review->getId(); + + if (!$customer instanceof CustomerInterface || !is_int($reviewId)) { + return; + } + + $this->eventDispatcher->dispatch(new ProductReviewApprovedTriggerEvent($customer, $reviewId)); + } + + /** + * The symfony/workflow entry point. + */ + public function onWorkflowCompleted(CompletedEvent $event): void + { + $review = $event->getSubject(); + if ($review instanceof ReviewInterface) { + $this->dispatch($review); + } + } +} diff --git a/src/EventListener/Doctrine/DiscriminatorMapListener.php b/src/EventListener/Doctrine/DiscriminatorMapListener.php new file mode 100644 index 0000000..bb39187 --- /dev/null +++ b/src/EventListener/Doctrine/DiscriminatorMapListener.php @@ -0,0 +1,54 @@ + $resources the + * %sylius.resources% parameter + */ + public function __construct( + private readonly string $transactionClass, + private readonly array $resources, + ) { + } + + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void + { + $metadata = $eventArgs->getClassMetadata(); + + if ($metadata->getName() !== $this->transactionClass) { + return; + } + + foreach ($this->resources as $resource) { + $model = $resource['classes']['model'] ?? null; + if (null === $model || !is_subclass_of($model, $this->transactionClass)) { + continue; + } + + $reflection = new \ReflectionClass($model); + if ($reflection->isAbstract() || in_array($model, $metadata->discriminatorMap, true)) { + continue; + } + + /** @var callable(): string $discriminator */ + $discriminator = [$model, 'getDiscriminator']; + + $metadata->addDiscriminatorMapClass($discriminator(), $model); + } + } +} diff --git a/src/EventListener/EarningTriggerListener.php b/src/EventListener/EarningTriggerListener.php new file mode 100644 index 0000000..0581a00 --- /dev/null +++ b/src/EventListener/EarningTriggerListener.php @@ -0,0 +1,25 @@ +processor->process($event); + } +} diff --git a/src/EventListener/RedeemPointsListener.php b/src/EventListener/RedeemPointsListener.php new file mode 100644 index 0000000..f81398f --- /dev/null +++ b/src/EventListener/RedeemPointsListener.php @@ -0,0 +1,51 @@ +appliedPointsProvider->getAppliedPoints($order); + if ($appliedPoints <= 0) { + return; + } + + // Balance and enabled state are re-validated inside the account lock + $this->ledger->redeem($order, $appliedPoints); + } + + /** + * The symfony/workflow entry point. + */ + public function onWorkflowTransition(TransitionEvent $event): void + { + $order = $event->getSubject(); + if ($order instanceof OrderInterface) { + $this->redeem($order); + } + } +} diff --git a/src/EventListener/ReferralQueryParameterListener.php b/src/EventListener/ReferralQueryParameterListener.php new file mode 100644 index 0000000..712d5cc --- /dev/null +++ b/src/EventListener/ReferralQueryParameterListener.php @@ -0,0 +1,63 @@ + $accountRepository + */ + public function __construct( + private readonly RepositoryInterface $accountRepository, + private readonly string $queryParameter, + ) { + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $code = $event->getRequest()->query->get($this->queryParameter); + if (!is_string($code)) { + return; + } + + $code = strtoupper($code); + if (!AttributionCookie::isValidFormat($code)) { + return; + } + + if (null === $this->accountRepository->findOneBy(['referralCode' => $code])) { + return; + } + + $this->capturedCode = $code; + } + + public function onResponse(ResponseEvent $event): void + { + if (null === $this->capturedCode || !$event->isMainRequest()) { + return; + } + + $event->getResponse()->headers->setCookie(AttributionCookie::create($this->capturedCode)); + $this->capturedCode = null; + } +} diff --git a/src/EventListener/RollbackRedemptionListener.php b/src/EventListener/RollbackRedemptionListener.php new file mode 100644 index 0000000..c24081e --- /dev/null +++ b/src/EventListener/RollbackRedemptionListener.php @@ -0,0 +1,41 @@ +ledger->rollbackRedeem($order); + } + + /** + * The symfony/workflow entry point. + */ + public function onWorkflowCompleted(CompletedEvent $event): void + { + $order = $event->getSubject(); + if ($order instanceof OrderInterface) { + $this->rollback($order); + } + } +} diff --git a/src/EventSubscriber/AdminMenuSubscriber.php b/src/EventSubscriber/AdminMenuSubscriber.php new file mode 100644 index 0000000..0d57704 --- /dev/null +++ b/src/EventSubscriber/AdminMenuSubscriber.php @@ -0,0 +1,36 @@ + 'addLoyaltyMenuItem', + ]; + } + + public function addLoyaltyMenuItem(MenuBuilderEvent $event): void + { + $marketing = $event->getMenu()->getChild('marketing'); + if (null === $marketing) { + return; + } + + $marketing + ->addChild('setono_sylius_loyalty', ['route' => 'setono_sylius_loyalty_admin_dashboard']) + ->setLabel('setono_sylius_loyalty.ui.loyalty') + ->setLabelAttribute('icon', 'star') + ; + } +} diff --git a/src/EventSubscriber/ShopAccountMenuSubscriber.php b/src/EventSubscriber/ShopAccountMenuSubscriber.php new file mode 100644 index 0000000..f8f48f3 --- /dev/null +++ b/src/EventSubscriber/ShopAccountMenuSubscriber.php @@ -0,0 +1,27 @@ + 'addLoyaltyMenuItem', + ]; + } + + public function addLoyaltyMenuItem(MenuBuilderEvent $event): void + { + $event->getMenu() + ->addChild('setono_sylius_loyalty', ['route' => 'setono_sylius_loyalty_shop_account_loyalty']) + ->setLabel('setono_sylius_loyalty.ui.my_loyalty') + ->setLabelAttribute('icon', 'star') + ; + } +} diff --git a/src/Exception/AccountNotFoundException.php b/src/Exception/AccountNotFoundException.php new file mode 100644 index 0000000..e493bf6 --- /dev/null +++ b/src/Exception/AccountNotFoundException.php @@ -0,0 +1,9 @@ +getId() ?? 'unsaved'), + $account->getBalance(), + $points, + )); + } +} diff --git a/src/Exception/InvalidExpressionException.php b/src/Exception/InvalidExpressionException.php new file mode 100644 index 0000000..bce387c --- /dev/null +++ b/src/Exception/InvalidExpressionException.php @@ -0,0 +1,13 @@ + $available + */ + public static function unknown(string $code, array $available): self + { + return new self(sprintf( + 'No tier qualification basis registered with code "%s" (available: %s)', + $code, + implode(', ', $available), + )); + } +} diff --git a/src/Exception/InvalidTriggerException.php b/src/Exception/InvalidTriggerException.php new file mode 100644 index 0000000..d1ac644 --- /dev/null +++ b/src/Exception/InvalidTriggerException.php @@ -0,0 +1,13 @@ +object, $method)) { + $value = (new \ReflectionMethod($this->object, $method))->invoke($this->object); + + return is_object($value) && !$value instanceof \DateTimeInterface ? new self($value) : $value; + } + } + + $properties = get_object_vars($this->object); + if (array_key_exists($name, $properties)) { + return $properties[$name]; + } + + throw new \RuntimeException(sprintf('Neither a getter nor a public property "%s" exists on "%s".', $name, $this->object::class)); + } + + public function __isset(string $name): bool + { + foreach (['get', 'is', 'has'] as $prefix) { + if (method_exists($this->object, $prefix . ucfirst($name))) { + return true; + } + } + + return property_exists($this->object, $name); + } +} diff --git a/src/Expression/ExpressionCatalog.php b/src/Expression/ExpressionCatalog.php new file mode 100644 index 0000000..45b5aa6 --- /dev/null +++ b/src/Expression/ExpressionCatalog.php @@ -0,0 +1,121 @@ +|null}> + */ + private const VARIABLES = [ + 'order' => [ + 'type' => 'order', + 'description' => 'setono_sylius_loyalty.expression.variable.order', + 'triggers' => [EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE], + ], + 'basis' => [ + 'type' => 'int', + 'description' => 'setono_sylius_loyalty.expression.variable.basis', + 'triggers' => [EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE], + ], + 'customer' => [ + 'type' => 'customer', + 'description' => 'setono_sylius_loyalty.expression.variable.customer', + 'triggers' => null, + ], + 'account' => [ + 'type' => 'account', + 'description' => 'setono_sylius_loyalty.expression.variable.account', + 'triggers' => null, + ], + 'channel' => [ + 'type' => 'channel', + 'description' => 'setono_sylius_loyalty.expression.variable.channel', + 'triggers' => null, + ], + 'context' => [ + 'type' => 'context', + 'description' => 'setono_sylius_loyalty.expression.variable.context', + 'triggers' => null, + ], + ]; + + /** + * @var array> + */ + private const TYPES = [ + 'order' => [ + 'total' => 'int', + 'itemsTotal' => 'int', + 'shippingTotal' => 'int', + 'number' => 'string', + 'currencyCode' => 'string', + 'customer' => 'customer', + 'channel' => 'channel', + ], + 'customer' => [ + 'email' => 'string', + 'firstName' => 'string', + 'lastName' => 'string', + 'group' => 'customer_group', + ], + 'customer_group' => [ + 'code' => 'string', + 'name' => 'string', + ], + 'account' => [ + 'balance' => 'int', + 'lifetimeEarned' => 'int', + 'enabled' => 'bool', + ], + 'channel' => [ + 'code' => 'string', + 'name' => 'string', + ], + ]; + + public function __construct( + private readonly ExpressionFunctionRegistryInterface $functions, + ) { + } + + public function getVariables(?string $trigger = null): array + { + if (null === $trigger) { + return self::VARIABLES; + } + + return array_filter( + self::VARIABLES, + static fn (array $variable): bool => null === $variable['triggers'] || in_array($trigger, $variable['triggers'], true), + ); + } + + public function getTypeMembers(string $type): array + { + return self::TYPES[$type] ?? []; + } + + public function toArray(): array + { + $functions = []; + foreach ($this->functions->all() as $function) { + $functions[] = [ + 'name' => $function->getName(), + 'signature' => $function->getSignature(), + 'description' => $function->getDescription(), + ]; + } + + return [ + 'variables' => self::VARIABLES, + 'types' => self::TYPES, + 'functions' => $functions, + ]; + } +} diff --git a/src/Expression/ExpressionCatalogInterface.php b/src/Expression/ExpressionCatalogInterface.php new file mode 100644 index 0000000..fa10e25 --- /dev/null +++ b/src/Expression/ExpressionCatalogInterface.php @@ -0,0 +1,37 @@ +|null}> + */ + public function getVariables(?string $trigger = null): array; + + /** + * The members of a catalog type: member name => type name (a scalar type or another + * catalog type). + * + * @return array + */ + public function getTypeMembers(string $type): array; + + /** + * The serializable form consumed by the expression editor. + * + * @return array + */ + public function toArray(): array; +} diff --git a/src/Expression/ExpressionEvaluator.php b/src/Expression/ExpressionEvaluator.php new file mode 100644 index 0000000..ed5af01 --- /dev/null +++ b/src/Expression/ExpressionEvaluator.php @@ -0,0 +1,82 @@ +validator->validate($expression); + + try { + // Expressions evaluate against the entities directly (rules are authored by + // administrators) through the generic getter bridge; the catalog whitelist is + // enforced at save and lint time by the validator + return $this->expressionLanguage()->evaluate($expression, [ + self::CONTEXT_VARIABLE => $context, + 'order' => null === $context->order ? null : new EntityAccess($context->order), + 'basis' => $basisOverride ?? $context->getBasis(), + 'customer' => null === $context->customer ? null : new EntityAccess($context->customer), + 'account' => null === $context->account ? null : new EntityAccess($context->account), + 'channel' => new EntityAccess($context->channel), + 'context' => (object) $context->context, + ]); + } catch (SyntaxError $e) { + throw new InvalidExpressionException($e->getMessage(), 0, $e); + } + } + + private function expressionLanguage(): ExpressionLanguage + { + if (null === $this->expressionLanguage) { + $expressionLanguage = new ExpressionLanguage(); + + foreach ($this->functions->all() as $function) { + $this->register($expressionLanguage, $function); + } + + $this->expressionLanguage = $expressionLanguage; + } + + return $this->expressionLanguage; + } + + private function register(ExpressionLanguage $expressionLanguage, ExpressionFunctionInterface $function): void + { + $expressionLanguage->register( + $function->getName(), + static function (): string { + throw new \LogicException('Loyalty expressions are never compiled'); + }, + static function (array $variables, mixed ...$arguments) use ($function): mixed { + $context = $variables[self::CONTEXT_VARIABLE]; + \assert($context instanceof EarningContext); + + return $function->evaluate($context, ...$arguments); + }, + ); + } +} diff --git a/src/Expression/ExpressionEvaluatorInterface.php b/src/Expression/ExpressionEvaluatorInterface.php new file mode 100644 index 0000000..54e8eea --- /dev/null +++ b/src/Expression/ExpressionEvaluatorInterface.php @@ -0,0 +1,22 @@ +catalog->getVariables($trigger); + + try { + $parsed = $this->expressionLanguage()->parse($expression, array_keys($variables)); + } catch (SyntaxError $e) { + throw new InvalidExpressionException($e->getMessage(), 0, $e); + } + + $this->walk($parsed->getNodes(), $variables); + } + + /** + * @param array|null}> $variables + */ + private function walk(Node $node, array $variables): void + { + if ($node instanceof GetAttrNode) { + $this->validateAttributeAccess($node, $variables); + } + + foreach (self::children($node) as $child) { + $this->walk($child, $variables); + } + } + + /** + * @param array|null}> $variables + */ + private function validateAttributeAccess(GetAttrNode $node, array $variables): void + { + $type = self::attribute($node, 'type'); + + if (GetAttrNode::METHOD_CALL === $type) { + throw new InvalidExpressionException('Method calls are not available in expressions'); + } + + if (GetAttrNode::ARRAY_CALL === $type) { + throw new InvalidExpressionException('Array access is not available in expressions; use dotted property access'); + } + + // Resolving the type validates the whole chain + $this->resolveType($node, $variables); + } + + /** + * Resolves the catalog type of a node in a dotted chain. Returns null for the dynamic + * trigger context, whose members are only known at dispatch time. + * + * @param array|null}> $variables + */ + private function resolveType(Node $node, array $variables): ?string + { + if ($node instanceof NameNode) { + $name = self::attribute($node, 'name'); + if (!is_string($name) || !isset($variables[$name])) { + throw new InvalidExpressionException(sprintf('Unknown variable "%s"', is_string($name) ? $name : '')); + } + + return $variables[$name]['type']; + } + + if ($node instanceof GetAttrNode) { + $parent = self::children($node)['node'] ?? null; + if (null === $parent) { + throw new InvalidExpressionException('Property access is only available on catalog variables'); + } + + $parentType = $this->resolveType($parent, $variables); + if (null === $parentType) { + // Dynamic context members cannot be checked statically + return null; + } + + $attributeNode = self::children($node)['attribute'] ?? null; + $attribute = $attributeNode instanceof ConstantNode ? self::attribute($attributeNode, 'value') : null; + if (!is_string($attribute)) { + throw new InvalidExpressionException('Dynamic property access is not available in expressions'); + } + + if ('context' === $parentType) { + return null; + } + + if (in_array($parentType, self::SCALAR_TYPES, true)) { + throw new InvalidExpressionException(sprintf('Cannot access "%s" on a %s value', $attribute, $parentType)); + } + + $members = $this->catalog->getTypeMembers($parentType); + if (!isset($members[$attribute])) { + throw new InvalidExpressionException(sprintf('"%s" is not available on "%s"', $attribute, $parentType)); + } + + return $members[$attribute]; + } + + throw new InvalidExpressionException('Property access is only available on catalog variables'); + } + + /** + * The Node base class declares its properties without types, so accesses are funneled + * through these helpers. + * + * @return array + */ + private static function children(Node $node): array + { + /** @var mixed $children */ + $children = $node->nodes; + if (!is_array($children)) { + return []; + } + + return array_filter($children, static fn (mixed $child): bool => $child instanceof Node); + } + + private static function attribute(Node $node, string $key): mixed + { + /** @var mixed $attributes */ + $attributes = $node->attributes; + if (!is_array($attributes)) { + return null; + } + + return $attributes[$key] ?? null; + } + + private function expressionLanguage(): ExpressionLanguage + { + if (null === $this->expressionLanguage) { + $this->expressionLanguage = new ExpressionLanguage(); + + foreach ($this->functions->all() as $function) { + $this->expressionLanguage->register( + $function->getName(), + static fn (): string => '', + static fn (): mixed => null, + ); + } + } + + return $this->expressionLanguage; + } +} diff --git a/src/Expression/ExpressionValidatorInterface.php b/src/Expression/ExpressionValidatorInterface.php new file mode 100644 index 0000000..8cdb2fd --- /dev/null +++ b/src/Expression/ExpressionValidatorInterface.php @@ -0,0 +1,22 @@ +getNow()->format('N'); + } +} diff --git a/src/Expression/Function/ExpressionFunctionInterface.php b/src/Expression/Function/ExpressionFunctionInterface.php new file mode 100644 index 0000000..326febd --- /dev/null +++ b/src/Expression/Function/ExpressionFunctionInterface.php @@ -0,0 +1,30 @@ + + */ +final class ExpressionFunctionRegistry extends CompositeService implements ExpressionFunctionRegistryInterface +{ + public function get(string $name): ?ExpressionFunctionInterface + { + foreach ($this->services as $function) { + if ($function->getName() === $name) { + return $function; + } + } + + return null; + } + + public function all(): array + { + return $this->services; + } +} diff --git a/src/Expression/Function/ExpressionFunctionRegistryInterface.php b/src/Expression/Function/ExpressionFunctionRegistryInterface.php new file mode 100644 index 0000000..8098bb2 --- /dev/null +++ b/src/Expression/Function/ExpressionFunctionRegistryInterface.php @@ -0,0 +1,15 @@ + + */ + public function all(): array; +} diff --git a/src/Expression/Function/IsFirstOrderFunction.php b/src/Expression/Function/IsFirstOrderFunction.php new file mode 100644 index 0000000..b9cb9ee --- /dev/null +++ b/src/Expression/Function/IsFirstOrderFunction.php @@ -0,0 +1,51 @@ +customer) { + return false; + } + + $count = $this->ordersCount->evaluate($context); + $paidOrders = is_int($count) ? $count : 0; + + // When the evaluated order is not itself counted as paid yet, it is counted on top + if (null !== $context->order && OrderPaymentStates::STATE_PAID !== $context->order->getPaymentState()) { + ++$paidOrders; + } + + return 1 === $paidOrders; + } +} diff --git a/src/Expression/Function/ItemsOfTaxonFunction.php b/src/Expression/Function/ItemsOfTaxonFunction.php new file mode 100644 index 0000000..d203971 --- /dev/null +++ b/src/Expression/Function/ItemsOfTaxonFunction.php @@ -0,0 +1,63 @@ +order) { + return 0; + } + + $units = 0; + foreach ($context->order->getItems() as $item) { + if ($item instanceof OrderItemInterface && self::hasTaxon($item, $taxonCode)) { + $units += $item->getQuantity(); + } + } + + return $units; + } + + private static function hasTaxon(OrderItemInterface $item, string $taxonCode): bool + { + $product = $item->getProduct(); + if (!$product instanceof ProductInterface) { + return false; + } + + foreach ($product->getTaxons() as $taxon) { + if ($taxon->getCode() === $taxonCode) { + return true; + } + } + + return false; + } +} diff --git a/src/Expression/Function/MathFunction.php b/src/Expression/Function/MathFunction.php new file mode 100644 index 0000000..4ee8002 --- /dev/null +++ b/src/Expression/Function/MathFunction.php @@ -0,0 +1,62 @@ +name; + } + + public function getSignature(): string + { + return match ($this->name) { + 'min', 'max' => sprintf('%s(...values: number): number', $this->name), + default => sprintf('%s(value: number): number', $this->name), + }; + } + + public function getDescription(): string + { + return sprintf('setono_sylius_loyalty.expression.function.%s', $this->name); + } + + public function evaluate(EarningContext $context, mixed ...$arguments): mixed + { + $numbers = array_map( + static fn (mixed $argument): float => is_numeric($argument) ? (float) $argument : 0.0, + array_values($arguments), + ); + + if ([] === $numbers) { + return 0.0; + } + + return match ($this->name) { + 'floor' => floor($numbers[0]), + 'ceil' => ceil($numbers[0]), + 'round' => round($numbers[0]), + 'abs' => abs($numbers[0]), + 'min' => min($numbers), + 'max' => max($numbers), + default => 0.0, + }; + } +} diff --git a/src/Expression/Function/OrdersCountFunction.php b/src/Expression/Function/OrdersCountFunction.php new file mode 100644 index 0000000..93acff9 --- /dev/null +++ b/src/Expression/Function/OrdersCountFunction.php @@ -0,0 +1,59 @@ +customer) { + return 0; + } + + return (int) $this->entityManager->createQueryBuilder() + ->select('COUNT(o.id)') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.channel = :channel') + ->andWhere('o.paymentState = :paymentState') + ->setParameter('customer', $context->customer) + ->setParameter('channel', $context->channel) + ->setParameter('paymentState', OrderPaymentStates::STATE_PAID) + ->getQuery() + ->getSingleScalarResult() + ; + } +} diff --git a/src/Expression/Function/ProductTotalFunction.php b/src/Expression/Function/ProductTotalFunction.php new file mode 100644 index 0000000..aa349a8 --- /dev/null +++ b/src/Expression/Function/ProductTotalFunction.php @@ -0,0 +1,46 @@ +order) { + return 0; + } + + $total = 0; + foreach ($context->order->getItems() as $item) { + if ($item instanceof OrderItemInterface && $item->getProduct()?->getCode() === $productCode) { + $total += $context->itemAmounts[(int) $item->getId()] ?? 0; + } + } + + return $total; + } +} diff --git a/src/Expression/Function/TaxonTotalFunction.php b/src/Expression/Function/TaxonTotalFunction.php new file mode 100644 index 0000000..a4c43e3 --- /dev/null +++ b/src/Expression/Function/TaxonTotalFunction.php @@ -0,0 +1,63 @@ +order) { + return 0; + } + + $total = 0; + foreach ($context->order->getItems() as $item) { + if ($item instanceof OrderItemInterface && self::hasTaxon($item, $taxonCode)) { + $total += $context->itemAmounts[(int) $item->getId()] ?? 0; + } + } + + return $total; + } + + private static function hasTaxon(OrderItemInterface $item, string $taxonCode): bool + { + $product = $item->getProduct(); + if (!$product instanceof ProductInterface) { + return false; + } + + foreach ($product->getTaxons() as $taxon) { + if ($taxon->getCode() === $taxonCode) { + return true; + } + } + + return false; + } +} diff --git a/src/Fixture/EarningRuleFixture.php b/src/Fixture/EarningRuleFixture.php new file mode 100644 index 0000000..e72a8d9 --- /dev/null +++ b/src/Fixture/EarningRuleFixture.php @@ -0,0 +1,37 @@ +children() + ->scalarNode('name')->cannotBeEmpty()->end() + ->scalarNode('channel')->cannotBeEmpty()->end() + ->scalarNode('trigger')->end() + ->scalarNode('scope')->end() + ->variableNode('scope_configuration')->end() + ->scalarNode('conditions_match')->end() + ->variableNode('conditions')->end() + ->scalarNode('amount_type')->cannotBeEmpty()->end() + ->variableNode('amount_configuration')->end() + ->integerNode('priority')->end() + ->booleanNode('stackable')->end() + ->booleanNode('enabled')->end() + ->booleanNode('dry_run')->end() + ->end() + ; + } +} diff --git a/src/Fixture/Factory/EarningRuleExampleFactory.php b/src/Fixture/Factory/EarningRuleExampleFactory.php new file mode 100644 index 0000000..697fa47 --- /dev/null +++ b/src/Fixture/Factory/EarningRuleExampleFactory.php @@ -0,0 +1,94 @@ + $ruleFactory + * @param FactoryInterface $conditionFactory + * @param ChannelRepositoryInterface $channelRepository + */ + public function __construct( + private readonly FactoryInterface $ruleFactory, + private readonly FactoryInterface $conditionFactory, + private readonly ChannelRepositoryInterface $channelRepository, + ) { + $this->optionsResolver = new OptionsResolver(); + + $this->optionsResolver + ->setRequired('name') + ->setAllowedTypes('name', 'string') + ->setRequired('channel') + ->setAllowedTypes('channel', 'string') + ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface { + $channel = $this->channelRepository->findOneByCode($code); + Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code)); + + return $channel; + }) + ->setDefault('trigger', EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE) + ->setDefault('scope', EarningRuleInterface::SCOPE_ORDER) + ->setDefault('scope_configuration', []) + ->setDefault('conditions_match', EarningRuleInterface::CONDITIONS_MATCH_ALL) + ->setDefault('conditions', []) + ->setAllowedTypes('conditions', 'array') + ->setRequired('amount_type') + ->setAllowedTypes('amount_type', 'string') + ->setDefault('amount_configuration', []) + ->setDefault('priority', 0) + ->setDefault('stackable', true) + ->setDefault('enabled', true) + ->setDefault('dry_run', false) + ; + } + + /** + * @param array $options + */ + public function create(array $options = []): EarningRuleInterface + { + /** @var array{name: string, channel: ChannelInterface, trigger: string, scope: string, scope_configuration: array, conditions_match: string, conditions: list}>, amount_type: string, amount_configuration: array, priority: int, stackable: bool, enabled: bool, dry_run: bool} $options */ + $options = $this->optionsResolver->resolve($options); + + $rule = $this->ruleFactory->createNew(); + $rule->setName($options['name']); + $rule->setChannel($options['channel']); + $rule->setTrigger($options['trigger']); + $rule->setScope($options['scope']); + $rule->setScopeConfiguration($options['scope_configuration']); + $rule->setConditionsMatch($options['conditions_match']); + $rule->setAmountType($options['amount_type']); + $rule->setAmountConfiguration($options['amount_configuration']); + $rule->setPriority($options['priority']); + $rule->setStackable($options['stackable']); + $rule->setEnabled($options['enabled']); + $rule->setDryRun($options['dry_run']); + + foreach ($options['conditions'] as $conditionOptions) { + $condition = $this->conditionFactory->createNew(); + $condition->setType($conditionOptions['type']); + $condition->setConfiguration($conditionOptions['configuration'] ?? []); + $rule->addCondition($condition); + } + + return $rule; + } +} diff --git a/src/Fixture/Factory/LoyaltyAccountExampleFactory.php b/src/Fixture/Factory/LoyaltyAccountExampleFactory.php new file mode 100644 index 0000000..98635e9 --- /dev/null +++ b/src/Fixture/Factory/LoyaltyAccountExampleFactory.php @@ -0,0 +1,176 @@ + $customerFactory + * @param FactoryInterface $shopUserFactory + * @param UserRepositoryInterface $shopUserRepository + * @param ChannelRepositoryInterface $channelRepository + */ + public function __construct( + private readonly LoyaltyAccountProviderInterface $accountProvider, + private readonly LoyaltyLedgerInterface $ledger, + private readonly FactoryInterface $customerFactory, + private readonly FactoryInterface $shopUserFactory, + private readonly UserRepositoryInterface $shopUserRepository, + private readonly ChannelRepositoryInterface $channelRepository, + private readonly ObjectManager $objectManager, + ) { + $this->optionsResolver = new OptionsResolver(); + + $this->optionsResolver + ->setRequired('email') + ->setAllowedTypes('email', 'string') + ->setRequired('channel') + ->setAllowedTypes('channel', 'string') + ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface { + $channel = $this->channelRepository->findOneByCode($code); + Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code)); + + return $channel; + }) + ->setDefault('password', 'sylius') + ->setDefault('enabled', true) + ->setDefault('history', []) + ->setAllowedTypes('history', 'array') + ; + } + + /** + * @param array $options + */ + public function create(array $options = []): LoyaltyAccountInterface + { + /** @var array{email: string, channel: ChannelInterface, password: string, enabled: bool, history: list>} $options */ + $options = $this->optionsResolver->resolve($options); + + $account = $this->accountProvider->getByCustomerAndChannel( + $this->customer($options['email'], $options['password']), + $options['channel'], + ); + + foreach ($options['history'] as $entry) { + $this->writeHistoryEntry($account, $entry); + } + + // Disabling happens after the history is written, so disabled sample accounts can + // still carry a balance + $account->setEnabled($options['enabled']); + + return $account; + } + + /** + * @param array $entry + */ + private function writeHistoryEntry(LoyaltyAccountInterface $account, array $entry): void + { + $type = $entry['type'] ?? null; + Assert::string($type, 'Every history entry needs a "type"'); + + $points = $entry['points'] ?? null; + Assert::integer($points, 'Every history entry needs integer "points"'); + + $expiresAt = null; + if (isset($entry['expires_at'])) { + Assert::string($entry['expires_at']); + $expiresAt = new \DateTimeImmutable($entry['expires_at']); + } + + switch ($type) { + case 'earn_action': + $sourceIdentifier = $entry['source_identifier'] ?? null; + Assert::string($sourceIdentifier, 'earn_action history entries need a "source_identifier"'); + $this->ledger->earnAction($account, $points, $sourceIdentifier, [], $expiresAt); + + break; + case 'manual_credit': + $this->ledger->manualCredit($account, $points, self::reason($entry), self::note($entry)); + + break; + case 'manual_debit': + $this->ledger->manualDebit($account, $points, self::reason($entry), self::note($entry)); + + break; + default: + throw new \InvalidArgumentException(sprintf( + 'Unsupported history entry type "%s" (supported: earn_action, manual_credit, manual_debit)', + $type, + )); + } + } + + private function customer(string $email, string $password): CustomerInterface + { + $existing = $this->shopUserRepository->findOneByEmail($email); + if ($existing instanceof ShopUserInterface && $existing->getCustomer() instanceof CustomerInterface) { + return $existing->getCustomer(); + } + + $customer = $this->customerFactory->createNew(); + $customer->setEmail($email); + $customer->setFirstName('Loyal'); + $customer->setLastName('Customer'); + + $shopUser = $this->shopUserFactory->createNew(); + $shopUser->setCustomer($customer); + $shopUser->setUsername($email); + $shopUser->setPlainPassword($password); + $shopUser->setEnabled(true); + + $this->objectManager->persist($customer); + $this->objectManager->persist($shopUser); + $this->objectManager->flush(); + + return $customer; + } + + /** + * @param array $entry + */ + private static function reason(array $entry): string + { + $reason = $entry['reason'] ?? 'goodwill'; + Assert::string($reason); + + return $reason; + } + + /** + * @param array $entry + */ + private static function note(array $entry): string + { + $note = $entry['note'] ?? 'Fixture history entry'; + Assert::string($note); + + return $note; + } +} diff --git a/src/Fixture/Factory/LoyaltyProgramExampleFactory.php b/src/Fixture/Factory/LoyaltyProgramExampleFactory.php new file mode 100644 index 0000000..ef35437 --- /dev/null +++ b/src/Fixture/Factory/LoyaltyProgramExampleFactory.php @@ -0,0 +1,79 @@ + $channelRepository + */ + public function __construct( + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly ChannelRepositoryInterface $channelRepository, + ) { + $this->optionsResolver = new OptionsResolver(); + + $this->optionsResolver + ->setRequired('channel') + ->setAllowedTypes('channel', 'string') + ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface { + $channel = $this->channelRepository->findOneByCode($code); + Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code)); + + return $channel; + }) + ->setDefault('award_order_points_at', LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID) + ->setDefault('earning_basis', LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL) + ->setDefault('include_taxes', false) + ->setDefault('rounding', LoyaltyProgramInterface::ROUNDING_FLOOR) + ->setDefault('redemption_conversion_points', 1) + ->setDefault('redemption_conversion_amount', 1) + ->setDefault('min_redeem_points', 500) + ->setDefault('max_redeem_percent_of_order', 50) + ->setDefault('points_expiry_days', 365) + ->setAllowedTypes('points_expiry_days', ['int', 'null']) + ->setDefault('clawback_policy', LoyaltyProgramInterface::CLAWBACK_POLICY_ALLOW_NEGATIVE) + ->setDefault('retroactive_guest_points', false) + ; + } + + /** + * @param array $options + */ + public function create(array $options = []): LoyaltyProgramInterface + { + /** @var array{channel: ChannelInterface, award_order_points_at: string, earning_basis: string, include_taxes: bool, rounding: string, redemption_conversion_points: int, redemption_conversion_amount: int, min_redeem_points: int, max_redeem_percent_of_order: int, points_expiry_days: int|null, clawback_policy: string, retroactive_guest_points: bool} $options */ + $options = $this->optionsResolver->resolve($options); + + $program = $this->programProvider->getByChannel($options['channel']); + $program->setAwardOrderPointsAt($options['award_order_points_at']); + $program->setEarningBasis($options['earning_basis']); + $program->setIncludeTaxes($options['include_taxes']); + $program->setRounding($options['rounding']); + $program->setRedemptionConversionPoints($options['redemption_conversion_points']); + $program->setRedemptionConversionAmount($options['redemption_conversion_amount']); + $program->setMinRedeemPoints($options['min_redeem_points']); + $program->setMaxRedeemPercentOfOrder($options['max_redeem_percent_of_order']); + $program->setPointsExpiryDays($options['points_expiry_days']); + $program->setClawbackPolicy($options['clawback_policy']); + $program->setRetroactiveGuestPoints($options['retroactive_guest_points']); + + return $program; + } +} diff --git a/src/Fixture/Factory/TierExampleFactory.php b/src/Fixture/Factory/TierExampleFactory.php new file mode 100644 index 0000000..9de5927 --- /dev/null +++ b/src/Fixture/Factory/TierExampleFactory.php @@ -0,0 +1,82 @@ + $tierFactory + * @param ChannelRepositoryInterface $channelRepository + */ + public function __construct( + private readonly FactoryInterface $tierFactory, + private readonly ChannelRepositoryInterface $channelRepository, + ) { + $this->optionsResolver = new OptionsResolver(); + + $this->optionsResolver + ->setRequired('code') + ->setAllowedTypes('code', 'string') + ->setRequired('name') + ->setAllowedTypes('name', 'string') + ->setRequired('channel') + ->setAllowedTypes('channel', 'string') + ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface { + $channel = $this->channelRepository->findOneByCode($code); + Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code)); + + return $channel; + }) + ->setDefault('position', 0) + ->setDefault('enabled', true) + ->setDefault('qualification_basis', PointsEarnedBasis::CODE) + ->setRequired('threshold') + ->setAllowedTypes('threshold', 'int') + ->setDefault('earning_multiplier', 1.0) + ->setDefault('color', null) + ->setDefault('benefits_description', null) + ; + } + + /** + * @param array $options + */ + public function create(array $options = []): TierInterface + { + /** @var array{code: string, name: string, channel: ChannelInterface, position: int, enabled: bool, qualification_basis: string, threshold: int, earning_multiplier: float, color: string|null, benefits_description: string|null} $options */ + $options = $this->optionsResolver->resolve($options); + + $tier = $this->tierFactory->createNew(); + $tier->setCode($options['code']); + $tier->setName($options['name']); + $tier->setChannel($options['channel']); + $tier->setPosition($options['position']); + $tier->setEnabled($options['enabled']); + $tier->setQualificationBasis($options['qualification_basis']); + $tier->setThreshold($options['threshold']); + $tier->setEarningMultiplier((float) $options['earning_multiplier']); + $tier->setColor($options['color']); + $tier->setCurrentLocale('en_US'); + $tier->setFallbackLocale('en_US'); + $tier->setBenefitsDescription($options['benefits_description']); + + return $tier; + } +} diff --git a/src/Fixture/LoyaltyAccountFixture.php b/src/Fixture/LoyaltyAccountFixture.php new file mode 100644 index 0000000..8396d4d --- /dev/null +++ b/src/Fixture/LoyaltyAccountFixture.php @@ -0,0 +1,29 @@ +children() + ->scalarNode('email')->cannotBeEmpty()->end() + ->scalarNode('channel')->cannotBeEmpty()->end() + ->scalarNode('password')->end() + ->booleanNode('enabled')->end() + ->variableNode('history')->end() + ->end() + ; + } +} diff --git a/src/Fixture/LoyaltyProgramFixture.php b/src/Fixture/LoyaltyProgramFixture.php new file mode 100644 index 0000000..0f71c9d --- /dev/null +++ b/src/Fixture/LoyaltyProgramFixture.php @@ -0,0 +1,36 @@ +children() + ->scalarNode('channel')->cannotBeEmpty()->end() + ->scalarNode('award_order_points_at')->end() + ->scalarNode('earning_basis')->end() + ->booleanNode('include_taxes')->end() + ->scalarNode('rounding')->end() + ->integerNode('redemption_conversion_points')->end() + ->integerNode('redemption_conversion_amount')->end() + ->integerNode('min_redeem_points')->end() + ->integerNode('max_redeem_percent_of_order')->end() + ->integerNode('points_expiry_days')->end() + ->scalarNode('clawback_policy')->end() + ->booleanNode('retroactive_guest_points')->end() + ->end() + ; + } +} diff --git a/src/Fixture/TierFixture.php b/src/Fixture/TierFixture.php new file mode 100644 index 0000000..1599ae3 --- /dev/null +++ b/src/Fixture/TierFixture.php @@ -0,0 +1,34 @@ +children() + ->scalarNode('code')->cannotBeEmpty()->end() + ->scalarNode('name')->cannotBeEmpty()->end() + ->scalarNode('channel')->cannotBeEmpty()->end() + ->integerNode('position')->end() + ->booleanNode('enabled')->end() + ->scalarNode('qualification_basis')->end() + ->integerNode('threshold')->end() + ->floatNode('earning_multiplier')->end() + ->scalarNode('color')->end() + ->scalarNode('benefits_description')->end() + ->end() + ; + } +} diff --git a/src/Form/Type/EarningRuleConditionType.php b/src/Form/Type/EarningRuleConditionType.php new file mode 100644 index 0000000..d6916b4 --- /dev/null +++ b/src/Form/Type/EarningRuleConditionType.php @@ -0,0 +1,112 @@ + + */ +final class EarningRuleConditionType extends AbstractType +{ + public function __construct( + private readonly ConditionCheckerRegistryInterface $conditionCheckers, + private readonly ExpressionValidatorInterface $expressionValidator, + private readonly string $dataClass, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = []; + foreach ($this->conditionCheckers->all() as $checker) { + $choices[$checker->getLabel()] = $checker->getType(); + } + + $builder + ->add('type', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_type', + 'choices' => $choices, + ]) + ->add('configuration', JsonType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_configuration', + 'required' => false, + 'help' => 'setono_sylius_loyalty.form.earning_rule.condition_configuration_help', + ]) + ->add('expression', ExpressionType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_expression', + 'help' => 'setono_sylius_loyalty.form.earning_rule.condition_expression_help', + 'mapped' => false, + ]) + ; + + $builder->addEventListener(FormEvents::POST_SET_DATA, self::populateExpression(...)); + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->handleSubmittedCondition(...)); + } + + private static function populateExpression(FormEvent $event): void + { + $condition = $event->getData(); + if (!$condition instanceof EarningRuleConditionInterface || ExpressionConditionChecker::TYPE !== $condition->getType()) { + return; + } + + $expression = $condition->getConfiguration()['expression'] ?? null; + if (is_string($expression)) { + $event->getForm()->get('expression')->setData($expression); + } + } + + /** + * Expression conditions are validated on save with the same parse + whitelist as the lint + * endpoint, narrowed to the rule's trigger when reachable. + */ + private function handleSubmittedCondition(FormEvent $event): void + { + $condition = $event->getData(); + if (!$condition instanceof EarningRuleConditionInterface || ExpressionConditionChecker::TYPE !== $condition->getType()) { + return; + } + + $form = $event->getForm(); + + $expression = $form->get('expression')->getData(); + if (!is_string($expression) || '' === trim($expression)) { + $form->get('expression')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.expression_required')); + + return; + } + + try { + $this->expressionValidator->validate($expression, $condition->getRule()?->getTrigger()); + $condition->setConfiguration(['expression' => $expression]); + } catch (InvalidExpressionException $e) { + $form->get('expression')->addError(new FormError($e->getMessage())); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->dataClass, + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_earning_rule_condition'; + } +} diff --git a/src/Form/Type/EarningRuleType.php b/src/Form/Type/EarningRuleType.php new file mode 100644 index 0000000..48010ce --- /dev/null +++ b/src/Form/Type/EarningRuleType.php @@ -0,0 +1,219 @@ + + */ +final class EarningRuleType extends AbstractType +{ + /** + * @param array}> $triggerCatalog + */ + public function __construct( + private readonly AmountCalculatorRegistryInterface $amountCalculators, + private readonly ExpressionValidatorInterface $expressionValidator, + private readonly array $triggerCatalog, + private readonly string $dataClass, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $triggerChoices = ['setono_sylius_loyalty.trigger.order_eligible' => EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE]; + foreach ($this->triggerCatalog as $code => $trigger) { + $triggerChoices[$trigger['label']] = $code; + } + + $amountChoices = []; + foreach ($this->amountCalculators->all() as $calculator) { + $amountChoices[$calculator->getLabel()] = $calculator->getType(); + } + + $builder + ->add('name', TextType::class, [ + 'label' => 'sylius.ui.name', + ]) + ->add('channel', ChannelChoiceType::class, [ + 'label' => 'sylius.ui.channel', + ]) + ->add('enabled', CheckboxType::class, [ + 'label' => 'sylius.ui.enabled', + 'required' => false, + ]) + ->add('dryRun', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.dry_run', + 'help' => 'setono_sylius_loyalty.form.earning_rule.dry_run_help', + 'required' => false, + ]) + ->add('priority', IntegerType::class, [ + 'label' => 'sylius.ui.priority', + ]) + ->add('trigger', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.trigger', + 'choices' => $triggerChoices, + ]) + ->add('scope', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.scope', + 'help' => 'setono_sylius_loyalty.form.earning_rule.scope_help', + 'choices' => [ + 'setono_sylius_loyalty.form.earning_rule.scope_order' => EarningRuleInterface::SCOPE_ORDER, + 'setono_sylius_loyalty.form.earning_rule.scope_taxon' => EarningRuleInterface::SCOPE_TAXON, + 'setono_sylius_loyalty.form.earning_rule.scope_product' => EarningRuleInterface::SCOPE_PRODUCT, + ], + ]) + ->add('scopeConfiguration', JsonType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.scope_configuration', + 'help' => 'setono_sylius_loyalty.form.earning_rule.scope_configuration_help', + 'required' => false, + ]) + ->add('conditionsMatch', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.conditions_match', + 'choices' => [ + 'setono_sylius_loyalty.form.earning_rule.conditions_match_all' => EarningRuleInterface::CONDITIONS_MATCH_ALL, + 'setono_sylius_loyalty.form.earning_rule.conditions_match_any' => EarningRuleInterface::CONDITIONS_MATCH_ANY, + ], + ]) + ->add('conditions', CollectionType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.conditions', + 'entry_type' => EarningRuleConditionType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'button_add_label' => 'setono_sylius_loyalty.form.earning_rule.add_condition', + ]) + ->add('amountType', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_type', + 'choices' => $amountChoices, + ]) + ->add('amountConfiguration', JsonType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_configuration', + 'help' => 'setono_sylius_loyalty.form.earning_rule.amount_configuration_help', + 'required' => false, + ]) + ->add('amountExpression', ExpressionType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_expression', + 'help' => 'setono_sylius_loyalty.form.earning_rule.amount_expression_help', + 'mapped' => false, + ]) + ->add('startsAt', DateTimeType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.starts_at', + 'help' => 'setono_sylius_loyalty.form.earning_rule.window_timezone_help', + 'widget' => 'single_text', + 'required' => false, + 'input' => 'datetime_immutable', + ]) + ->add('endsAt', DateTimeType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.ends_at', + 'help' => 'setono_sylius_loyalty.form.earning_rule.window_timezone_help', + 'widget' => 'single_text', + 'required' => false, + 'input' => 'datetime_immutable', + ]) + ->add('stackable', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.earning_rule.stackable', + 'help' => 'setono_sylius_loyalty.form.earning_rule.stackable_help', + 'required' => false, + ]) + ; + + $builder->addEventListener(FormEvents::POST_SET_DATA, self::populateAmountExpression(...)); + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->handleSubmittedRule(...)); + } + + /** + * Expression mode stores the amount expression inside the amount configuration; the + * dedicated editor field is populated from it. + */ + private static function populateAmountExpression(FormEvent $event): void + { + $rule = $event->getData(); + if (!$rule instanceof EarningRuleInterface || ExpressionAmountCalculator::TYPE !== $rule->getAmountType()) { + return; + } + + $expression = $rule->getAmountConfiguration()['expression'] ?? null; + if (is_string($expression)) { + $event->getForm()->get('amountExpression')->setData($expression); + } + } + + /** + * Validates expression mode on save (same parse + whitelist as the lint endpoint, narrowed + * to the rule's trigger) and enforces the trigger-kind constraints: scope, per_amount, and + * multiplier need an order and a basis, so they are only meaningful for the built-in order + * trigger; multipliers are order-scoped only. + */ + private function handleSubmittedRule(FormEvent $event): void + { + $rule = $event->getData(); + if (!$rule instanceof EarningRuleInterface) { + return; + } + + $form = $event->getForm(); + + if (ExpressionAmountCalculator::TYPE === $rule->getAmountType()) { + $expression = $form->get('amountExpression')->getData(); + if (!is_string($expression) || '' === trim($expression)) { + $form->get('amountExpression')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.expression_required')); + } else { + try { + $this->expressionValidator->validate($expression, $rule->getTrigger()); + $rule->setAmountConfiguration(['expression' => $expression]); + } catch (InvalidExpressionException $e) { + $form->get('amountExpression')->addError(new FormError($e->getMessage())); + } + } + } + + $isOrderTrigger = EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE === $rule->getTrigger(); + + if (!$isOrderTrigger && EarningRuleInterface::SCOPE_ORDER !== $rule->getScope()) { + $form->get('scope')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.scope_requires_order_trigger')); + } + + if (!$isOrderTrigger && in_array($rule->getAmountType(), [PerAmountCalculator::TYPE, MultiplierAmountCalculator::TYPE], true)) { + $form->get('amountType')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.amount_requires_order_trigger')); + } + + if (MultiplierAmountCalculator::TYPE === $rule->getAmountType() && EarningRuleInterface::SCOPE_ORDER !== $rule->getScope()) { + $form->get('amountType')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.multiplier_is_order_scoped')); + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->dataClass, + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_earning_rule'; + } +} diff --git a/src/Form/Type/ExpressionType.php b/src/Form/Type/ExpressionType.php new file mode 100644 index 0000000..26d6fa2 --- /dev/null +++ b/src/Form/Type/ExpressionType.php @@ -0,0 +1,73 @@ + + */ +final class ExpressionType extends AbstractType +{ + public function __construct( + private readonly ExpressionCatalogInterface $catalog, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly string $cdnBaseUrl, + ) { + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + /** @var string|null $trigger */ + $trigger = $options['trigger']; + + /** @var array $vars */ + $vars = $view->vars; + + /** @var array $attr */ + $attr = $vars['attr'] ?? []; + + $vars['attr'] = array_merge($attr, [ + 'data-setono-sylius-loyalty-expression' => '1', + 'data-catalog' => (string) json_encode($this->catalog->toArray(), \JSON_THROW_ON_ERROR), + 'data-lint-url' => $this->urlGenerator->generate('setono_sylius_loyalty_admin_expression_lint'), + 'data-cdn-base-url' => $this->cdnBaseUrl, + 'data-trigger' => $trigger ?? '', + 'rows' => 3, + ]); + + $view->vars = $vars; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'trigger' => null, + 'required' => false, + ]); + $resolver->setAllowedTypes('trigger', ['string', 'null']); + } + + public function getParent(): string + { + return TextareaType::class; + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_expression'; + } +} diff --git a/src/Form/Type/JsonType.php b/src/Form/Type/JsonType.php new file mode 100644 index 0000000..2e80625 --- /dev/null +++ b/src/Form/Type/JsonType.php @@ -0,0 +1,62 @@ + + */ +final class JsonType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new CallbackTransformer( + static function (mixed $value): string { + if (null === $value || [] === $value) { + return '{}'; + } + + return (string) json_encode($value, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES); + }, + static function (mixed $value): array { + if (!is_string($value) || '' === trim($value)) { + return []; + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($value, true, 32, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new TransformationFailedException($e->getMessage(), 0, $e, 'setono_sylius_loyalty.form.invalid_json'); + } + + if (!is_array($decoded)) { + throw new TransformationFailedException('Expected a JSON object', 0, null, 'setono_sylius_loyalty.form.invalid_json'); + } + + return $decoded; + }, + )); + } + + public function getParent(): string + { + return TextareaType::class; + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_json'; + } +} diff --git a/src/Form/Type/LoyaltyProgramType.php b/src/Form/Type/LoyaltyProgramType.php new file mode 100644 index 0000000..b13b93c --- /dev/null +++ b/src/Form/Type/LoyaltyProgramType.php @@ -0,0 +1,126 @@ + + */ +final class LoyaltyProgramType extends AbstractType +{ + public function __construct( + private readonly string $dataClass, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('awardOrderPointsAt', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.award_order_points_at', + 'help' => 'setono_sylius_loyalty.form.program.award_order_points_at_help', + 'choices' => [ + 'setono_sylius_loyalty.form.program.award_moment_payment_paid' => LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID, + 'setono_sylius_loyalty.form.program.award_moment_order_fulfilled' => LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED, + ], + ]) + ->add('earningBasis', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.earning_basis', + 'choices' => [ + 'setono_sylius_loyalty.form.program.earning_basis_items_total' => LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL, + 'setono_sylius_loyalty.form.program.earning_basis_order_total' => LoyaltyProgramInterface::EARNING_BASIS_ORDER_TOTAL, + ], + 'help' => 'setono_sylius_loyalty.form.program.earning_basis_help', + ]) + ->add('includeTaxes', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.include_taxes', + 'required' => false, + ]) + ->add('rounding', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.rounding', + 'choices' => [ + 'setono_sylius_loyalty.form.program.rounding_floor' => LoyaltyProgramInterface::ROUNDING_FLOOR, + 'setono_sylius_loyalty.form.program.rounding_round' => LoyaltyProgramInterface::ROUNDING_ROUND, + 'setono_sylius_loyalty.form.program.rounding_ceil' => LoyaltyProgramInterface::ROUNDING_CEIL, + ], + ]) + ->add('redemptionConversionPoints', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.redemption_conversion_points', + 'help' => 'setono_sylius_loyalty.form.program.redemption_conversion_help', + ]) + ->add('redemptionConversionAmount', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.redemption_conversion_amount', + 'help' => 'setono_sylius_loyalty.form.program.base_currency_help', + ]) + ->add('minRedeemPoints', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.min_redeem_points', + ]) + ->add('maxRedeemPercentOfOrder', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.max_redeem_percent_of_order', + 'help' => 'setono_sylius_loyalty.form.program.max_redeem_percent_of_order_help', + ]) + ->add('pointsExpiryDays', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.points_expiry_days', + 'help' => 'setono_sylius_loyalty.form.program.points_expiry_days_help', + 'required' => false, + ]) + ->add('clawbackPolicy', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.clawback_policy', + 'choices' => [ + 'setono_sylius_loyalty.form.program.clawback_policy_allow_negative' => LoyaltyProgramInterface::CLAWBACK_POLICY_ALLOW_NEGATIVE, + 'setono_sylius_loyalty.form.program.clawback_policy_clamp_to_zero' => LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO, + ], + ]) + ->add('retroactiveGuestPoints', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.retroactive_guest_points', + 'help' => 'setono_sylius_loyalty.form.program.retroactive_guest_points_help', + 'required' => false, + ]) + ->add('tierEvaluationWindow', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.tier_evaluation_window', + 'choices' => [ + 'setono_sylius_loyalty.form.program.tier_window_calendar_year' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_CALENDAR_YEAR, + 'setono_sylius_loyalty.form.program.tier_window_rolling_12_months' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_ROLLING_12_MONTHS, + 'setono_sylius_loyalty.form.program.tier_window_lifetime' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_LIFETIME, + ], + ]) + ->add('tierDowngradeGraceDays', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.tier_downgrade_grace_days', + 'help' => 'setono_sylius_loyalty.form.program.tier_downgrade_grace_days_help', + ]) + ->add('showEarnableOnProduct', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.show_earnable_on_product', + 'required' => false, + ]) + ->add('showEarnableInCart', CheckboxType::class, [ + 'label' => 'setono_sylius_loyalty.form.program.show_earnable_in_cart', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->dataClass, + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_program'; + } +} diff --git a/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php b/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php new file mode 100644 index 0000000..2353a9a --- /dev/null +++ b/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php @@ -0,0 +1,44 @@ + + */ +final class CustomerLoyaltyTierConfigurationType extends AbstractType +{ + public function __construct( + private readonly TierRepositoryInterface $tierRepository, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = []; + /** @var TierInterface $tier */ + foreach ($this->tierRepository->findAll() as $tier) { + $label = sprintf('%s (%s)', (string) $tier->getName(), (string) $tier->getChannel()?->getCode()); + $choices[$label] = $tier->getCode(); + } + + $builder->add('tier', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.promotion_rule.minimum_tier', + 'choices' => $choices, + 'constraints' => [new NotBlank(['groups' => ['sylius']])], + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_promotion_rule_customer_loyalty_tier'; + } +} diff --git a/src/Form/Type/TierTranslationType.php b/src/Form/Type/TierTranslationType.php new file mode 100644 index 0000000..4f7ed0c --- /dev/null +++ b/src/Form/Type/TierTranslationType.php @@ -0,0 +1,41 @@ + + */ +final class TierTranslationType extends AbstractType +{ + public function __construct( + private readonly string $dataClass, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('benefitsDescription', TextareaType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.benefits_description', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->dataClass, + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_tier_translation'; + } +} diff --git a/src/Form/Type/TierType.php b/src/Form/Type/TierType.php new file mode 100644 index 0000000..2129dbf --- /dev/null +++ b/src/Form/Type/TierType.php @@ -0,0 +1,94 @@ + + */ +final class TierType extends AbstractType +{ + public function __construct( + private readonly TierQualificationBasisRegistryInterface $basisRegistry, + private readonly string $dataClass, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $basisChoices = []; + $unitLabels = []; + foreach ($this->basisRegistry->all() as $basis) { + $basisChoices[$basis->getLabel()] = $basis->getCode(); + $unitLabels[$basis->getCode()] = $basis->getUnitLabel(); + } + + $builder + ->add('code', TextType::class, [ + 'label' => 'sylius.ui.code', + ]) + ->add('name', TextType::class, [ + 'label' => 'sylius.ui.name', + ]) + ->add('channel', ChannelChoiceType::class, [ + 'label' => 'sylius.ui.channel', + ]) + ->add('position', IntegerType::class, [ + 'label' => 'sylius.ui.position', + 'help' => 'setono_sylius_loyalty.form.tier.position_help', + ]) + ->add('enabled', CheckboxType::class, [ + 'label' => 'sylius.ui.enabled', + 'required' => false, + ]) + ->add('qualificationBasis', ChoiceType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.qualification_basis', + 'choices' => $basisChoices, + 'choice_attr' => fn (string $code): array => ['data-unit-label' => $unitLabels[$code] ?? ''], + ]) + ->add('threshold', IntegerType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.threshold', + 'help' => 'setono_sylius_loyalty.form.tier.threshold_help', + ]) + ->add('earningMultiplier', NumberType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.earning_multiplier', + 'help' => 'setono_sylius_loyalty.form.tier.earning_multiplier_help', + 'scale' => 2, + ]) + ->add('color', ColorType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.color', + 'required' => false, + ]) + ->add('translations', ResourceTranslationsType::class, [ + 'label' => 'setono_sylius_loyalty.form.tier.benefits', + 'entry_type' => TierTranslationType::class, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => $this->dataClass, + ]); + } + + public function getBlockPrefix(): string + { + return 'setono_sylius_loyalty_tier'; + } +} diff --git a/src/Hint/EarnHintCalculator.php b/src/Hint/EarnHintCalculator.php new file mode 100644 index 0000000..b28afe0 --- /dev/null +++ b/src/Hint/EarnHintCalculator.php @@ -0,0 +1,122 @@ +syntheticCartFactory->create($variant, $channel); + if (null === $cart) { + return null; + } + + return $this->evaluate($cart, $channel, $customer, syntheticCart: true); + } + + public function forCart(OrderInterface $cart, ?CustomerInterface $customer): ?int + { + $channel = $cart->getChannel(); + if (!$channel instanceof ChannelInterface || $cart->isEmpty()) { + return null; + } + + return $this->evaluate($cart, $channel, $customer, syntheticCart: false); + } + + private function evaluate(OrderInterface $cart, ChannelInterface $channel, ?CustomerInterface $customer, bool $syntheticCart): ?int + { + $account = null; + if ($customer instanceof CustomerInterface) { + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + // Hints are hidden entirely for customers whose account is disabled + if (null !== $account && !$account->isEnabled()) { + return null; + } + } + + $rules = array_values(array_filter( + $this->ruleRepository->findForEvaluation($channel, EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE), + fn (EarningRuleInterface $rule): bool => $this->isEvaluable($rule, null !== $customer, $syntheticCart), + )); + if ([] === $rules) { + return null; + } + + $program = $this->programProvider->getByChannel($channel); + $basis = $this->basisCalculator->calculate($cart, $program); + + $context = new EarningContext( + channel: $channel, + customer: $customer, + account: $account, + order: $cart, + itemAmounts: $basis->itemAmounts, + extraAmount: $basis->extraAmount, + ); + + $points = $this->evaluator->evaluate($rules, $context, $program)->points; + + return $points > 0 ? $points : null; + } + + private function isEvaluable(EarningRuleInterface $rule, bool $hasCustomer, bool $syntheticCart): bool + { + if ($rule->isDryRun()) { + return false; + } + + if (!$hasCustomer && 'expression' === $rule->getAmountType()) { + return false; + } + + foreach ($rule->getConditions() as $condition) { + $checker = $this->conditionCheckers->get((string) $condition->getType()); + if (null === $checker) { + return false; + } + + if ($checker->requiresCustomer() && !$hasCustomer) { + return false; + } + + if ($checker->requiresCart() && $syntheticCart) { + return false; + } + } + + return true; + } +} diff --git a/src/Hint/EarnHintCalculatorInterface.php b/src/Hint/EarnHintCalculatorInterface.php new file mode 100644 index 0000000..b3f0f22 --- /dev/null +++ b/src/Hint/EarnHintCalculatorInterface.php @@ -0,0 +1,24 @@ + $orderFactory + * @param FactoryInterface $orderItemFactory + */ + public function __construct( + private readonly FactoryInterface $orderFactory, + private readonly FactoryInterface $orderItemFactory, + private readonly OrderItemQuantityModifierInterface $quantityModifier, + ) { + } + + public function create(ProductVariantInterface $variant, ChannelInterface $channel): ?OrderInterface + { + $channelPricing = $variant->getChannelPricingForChannel($channel); + if (null === $channelPricing || null === $channelPricing->getPrice()) { + return null; + } + + $order = $this->orderFactory->createNew(); + $order->setChannel($channel); + $order->setCurrencyCode((string) $channel->getBaseCurrency()?->getCode()); + + $item = $this->orderItemFactory->createNew(); + $item->setVariant($variant); + $this->quantityModifier->modify($item, 1); + $item->setUnitPrice($channelPricing->getPrice()); + $order->addItem($item); + + self::assignSyntheticId($item); + + return $order; + } + + /** + * The claiming pipeline correlates items and eligible amounts by item id, which in-memory + * items lack. + */ + private static function assignSyntheticId(OrderItemInterface $item): void + { + $reflection = new \ReflectionProperty($item, 'id'); + $reflection->setValue($item, 1); + Assert::notNull($item->getId()); + } +} diff --git a/src/Hint/SyntheticCartFactoryInterface.php b/src/Hint/SyntheticCartFactoryInterface.php new file mode 100644 index 0000000..04fb13d --- /dev/null +++ b/src/Hint/SyntheticCartFactoryInterface.php @@ -0,0 +1,17 @@ + $errors ledger corruption (never auto-fixed) + * @param list $warnings expected operational conditions, e.g. overdue open lots + * when the expiry cron is behind or a listener deferred an expiration + */ + public function __construct( + public readonly LoyaltyAccountInterface $account, + public readonly ReplayResult $replay, + public readonly array $errors, + public readonly array $warnings, + ) { + } + + public function isHealthy(): bool + { + return [] === $this->errors; + } + + /** + * @return array + */ + public function toArray(): array + { + $lots = []; + foreach ($this->replay->lots as $lotState) { + $consumptions = []; + foreach ($lotState->getConsumptions() as $consumption) { + $consumptions[] = [ + 'debit' => $consumption->debit->getId(), + 'points' => $consumption->points, + ]; + } + + $lots[] = [ + 'lot' => $lotState->lot->getId(), + 'points' => $lotState->lot->getPoints(), + 'expiresAt' => $lotState->lot->getExpiresAt()?->format(\DateTimeInterface::ATOM), + 'remaining' => $lotState->getRemaining(), + 'closedByExpiration' => $lotState->isClosedByExpiration(), + 'consumptions' => $consumptions, + ]; + } + + return [ + 'account' => [ + 'id' => $this->account->getId(), + 'customer' => $this->account->getCustomer()?->getEmail(), + 'channel' => $this->account->getChannel()?->getCode(), + 'enabled' => $this->account->isEnabled(), + 'balance' => $this->account->getBalance(), + 'lifetimeEarned' => $this->account->getLifetimeEarned(), + 'derivedBalance' => $this->replay->balance, + ], + 'lots' => $lots, + 'errors' => $this->errors, + 'warnings' => $this->warnings, + ]; + } +} diff --git a/src/Inspector/AccountInspector.php b/src/Inspector/AccountInspector.php new file mode 100644 index 0000000..03bc62d --- /dev/null +++ b/src/Inspector/AccountInspector.php @@ -0,0 +1,69 @@ + 0 — the expiry cron + * is behind or a listener deferred the expiration. + */ +final class AccountInspector implements AccountInspectorInterface +{ + public function __construct( + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly LotReplayerInterface $lotReplayer, + ) { + } + + public function inspect(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): AccountInspection + { + $now ??= new \DateTimeImmutable(); + $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account)); + + $errors = $replay->anomalies; + + if ($replay->balance !== $account->getBalance()) { + $errors[] = sprintf( + 'The cached balance (%d) differs from the ledger sum (%d)', + $account->getBalance(), + $replay->balance, + ); + } + + foreach ($replay->expirations as $expiration) { + if (abs($expiration->expiration->getPoints()) !== $expiration->remainingBefore) { + $errors[] = sprintf( + 'Expiration entry (id: %s) debits %d points but its lot had %d remaining at that moment', + (string) $expiration->expiration->getId(), + abs($expiration->expiration->getPoints()), + $expiration->remainingBefore, + ); + } + } + + $warnings = []; + foreach ($replay->lots as $lotState) { + $expiresAt = $lotState->lot->getExpiresAt(); + if ($lotState->isOpen() && null !== $expiresAt && $expiresAt < $now) { + $warnings[] = sprintf( + 'Lot (id: %s) expired %s with %d points still open — the expiry cron is behind or a listener deferred it', + (string) $lotState->lot->getId(), + $expiresAt->format('Y-m-d'), + $lotState->getRemaining(), + ); + } + } + + return new AccountInspection($account, $replay, $errors, $warnings); + } +} diff --git a/src/Inspector/AccountInspectorInterface.php b/src/Inspector/AccountInspectorInterface.php new file mode 100644 index 0000000..a072470 --- /dev/null +++ b/src/Inspector/AccountInspectorInterface.php @@ -0,0 +1,12 @@ +sortIntoReplayOrder($transactions); + + /** @var list $lots */ + $lots = []; + + /** @var \SplObjectStorage $lotIndex */ + $lotIndex = new \SplObjectStorage(); + + /** @var list $expirations */ + $expirations = []; + + /** @var list $anomalies */ + $anomalies = []; + + /** @var list $deficits */ + $deficits = []; + + $balance = 0; + + foreach ($transactions as $transaction) { + $balance += $transaction->getPoints(); + + if ($transaction instanceof ExpireLoyaltyTransactionInterface) { + $lot = $transaction->getLot(); + if (null === $lot || !isset($lotIndex[$lot])) { + $anomalies[] = sprintf( + 'Expiration entry (id: %s) references a lot that does not precede it in the ledger', + self::describeId($transaction), + ); + + continue; + } + + $lotState = $lotIndex[$lot]; + $expirations[] = new ExpirationState($transaction, $lotState->getRemaining()); + $lotState->closeByExpiration(); + + continue; + } + + if ($transaction instanceof CreditLoyaltyTransactionInterface) { + if ($transaction->getPoints() < 0) { + $anomalies[] = sprintf('Credit (id: %s) has negative points', self::describeId($transaction)); + } + + $lotState = new LotState($transaction, max(0, $transaction->getPoints())); + $lots[] = $lotState; + $lotIndex[$transaction] = $lotState; + + $deficits = $this->serveDeficits($lotState, $deficits); + + continue; + } + + if ($transaction instanceof DebitLoyaltyTransactionInterface) { + if ($transaction->getPoints() > 0) { + $anomalies[] = sprintf('Debit (id: %s) has positive points', self::describeId($transaction)); + } + + $shortfall = $this->consume($transaction, abs($transaction->getPoints()), $lots); + if ($shortfall > 0) { + $deficits[] = ['debit' => $transaction, 'points' => $shortfall]; + } + + continue; + } + + $anomalies[] = sprintf( + 'Transaction (id: %s) is neither a credit nor a debit and cannot be replayed', + self::describeId($transaction), + ); + } + + return new ReplayResult( + $lots, + $expirations, + $balance, + array_sum(array_column($deficits, 'points')), + $anomalies, + ); + } + + /** + * Consumes points from open, non-expired lots in consumption order and returns the + * unserved remainder. + * + * @param list $lots + */ + private function consume(DebitLoyaltyTransactionInterface $debit, int $points, array $lots): int + { + $candidates = array_filter( + $lots, + static function (LotState $lotState) use ($debit): bool { + if (!$lotState->isOpen()) { + return false; + } + + $expiresAt = $lotState->lot->getExpiresAt(); + + return null === $expiresAt || $expiresAt > $debit->getOccurredAt(); + }, + ); + + usort($candidates, self::compareConsumptionOrder(...)); + + foreach ($candidates as $lotState) { + if ($points <= 0) { + break; + } + + $consumed = min($lotState->getRemaining(), $points); + $lotState->consume(new Consumption($debit, $consumed)); + $points -= $consumed; + } + + return $points; + } + + /** + * Serves a carried deficit from a newly opened lot, oldest debit first. + * + * @param list $deficits + * + * @return list + */ + private function serveDeficits(LotState $lotState, array $deficits): array + { + foreach ($deficits as $key => $deficit) { + if ($lotState->getRemaining() <= 0) { + break; + } + + $consumed = min($lotState->getRemaining(), $deficit['points']); + $lotState->consume(new Consumption($deficit['debit'], $consumed)); + $deficits[$key]['points'] -= $consumed; + } + + return array_values(array_filter( + $deficits, + static fn (array $deficit): bool => $deficit['points'] > 0, + )); + } + + /** + * @param iterable $transactions + * + * @return list + */ + private function sortIntoReplayOrder(iterable $transactions): array + { + $transactions = is_array($transactions) ? array_values($transactions) : iterator_to_array($transactions, false); + + usort( + $transactions, + static fn (LoyaltyTransactionInterface $a, LoyaltyTransactionInterface $b): int => [$a->getOccurredAt(), self::idOf($a)] <=> [$b->getOccurredAt(), self::idOf($b)], + ); + + return $transactions; + } + + private static function compareConsumptionOrder(LotState $a, LotState $b): int + { + $aExpiresAt = $a->lot->getExpiresAt(); + $bExpiresAt = $b->lot->getExpiresAt(); + + // expiresAt ASC NULLS LAST + if (null === $aExpiresAt xor null === $bExpiresAt) { + return null === $aExpiresAt ? 1 : -1; + } + + return [$aExpiresAt, $a->lot->getOccurredAt(), self::idOf($a->lot)] + <=> [$bExpiresAt, $b->lot->getOccurredAt(), self::idOf($b->lot)]; + } + + private static function idOf(LoyaltyTransactionInterface $transaction): int + { + $id = $transaction->getId(); + + return is_int($id) ? $id : \PHP_INT_MAX; + } + + private static function describeId(LoyaltyTransactionInterface $transaction): string + { + $id = $transaction->getId(); + + return null === $id ? 'unsaved' : (string) $id; + } +} diff --git a/src/Ledger/LotReplayerInterface.php b/src/Ledger/LotReplayerInterface.php new file mode 100644 index 0000000..ef7c764 --- /dev/null +++ b/src/Ledger/LotReplayerInterface.php @@ -0,0 +1,20 @@ + $transactions the account's ledger; it is + * re-sorted into replay order (occurredAt ASC, id ASC) internally + */ + public function replay(iterable $transactions): ReplayResult; +} diff --git a/src/Ledger/LotState.php b/src/Ledger/LotState.php new file mode 100644 index 0000000..71ed155 --- /dev/null +++ b/src/Ledger/LotState.php @@ -0,0 +1,66 @@ + */ + private array $consumptions = []; + + private bool $closedByExpiration = false; + + public function __construct( + public readonly CreditLoyaltyTransactionInterface $lot, + private int $remaining, + ) { + } + + public function getRemaining(): int + { + return $this->remaining; + } + + /** + * @return list + */ + public function getConsumptions(): array + { + return $this->consumptions; + } + + public function isClosedByExpiration(): bool + { + return $this->closedByExpiration; + } + + public function isOpen(): bool + { + return $this->remaining > 0 && !$this->closedByExpiration; + } + + /** + * @internal only the LotReplayer mutates lot states + */ + public function consume(Consumption $consumption): void + { + $this->remaining -= $consumption->points; + $this->consumptions[] = $consumption; + } + + /** + * @internal only the LotReplayer mutates lot states + */ + public function closeByExpiration(): void + { + $this->remaining = 0; + $this->closedByExpiration = true; + } +} diff --git a/src/Ledger/LoyaltyLedger.php b/src/Ledger/LoyaltyLedger.php new file mode 100644 index 0000000..a36b64a --- /dev/null +++ b/src/Ledger/LoyaltyLedger.php @@ -0,0 +1,655 @@ + $accountClass + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly ManagerRegistry $managerRegistry, + private readonly LoyaltyAccountProviderInterface $accountProvider, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly LotReplayerInterface $lotReplayer, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly TierEvaluatorInterface $tierEvaluator, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface $logger, + private readonly string $accountClass, + ) { + } + + public function earnOrder( + OrderInterface $order, + int $points, + array $rulesBreakdown = [], + ?int $basisAmount = null, + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnOrderLoyaltyTransactionInterface { + Assert::greaterThan($points, 0); + + $account = $this->accountFromOrder($order); + if (null === $account) { + return null; + } + + if (!$account->isEnabled()) { + $this->logger->info(sprintf( + '[Loyalty] Skipped earning for order %s: the loyalty account (id: %d) is disabled', + (string) $order->getNumber(), + (int) $account->getId(), + )); + + return null; + } + + /** @var EarnOrderLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $rulesBreakdown, $basisAmount, $expiresAt): ?EarnOrderLoyaltyTransactionInterface { + $awarding = new AwardingPoints($account, $points, $expiresAt, $order, null, $rulesBreakdown); + $this->eventDispatcher->dispatch($awarding); + if ($awarding->isCancelled() || $awarding->getPoints() <= 0) { + return null; + } + + $transaction = new EarnOrderLoyaltyTransaction(); + $transaction->setOrder($order); + $transaction->setRulesBreakdown($rulesBreakdown); + $transaction->setBasisAmount($basisAmount); + + $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt()); + + $this->evaluateTier($account); + + $postCommitEvents[] = new PointsEarned($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function earnAction( + LoyaltyAccountInterface $account, + int $points, + string $sourceIdentifier, + array $rulesBreakdown = [], + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnActionLoyaltyTransactionInterface { + Assert::greaterThan($points, 0); + + if (!$account->isEnabled()) { + $this->logger->info(sprintf( + '[Loyalty] Skipped earning for source "%s": the loyalty account (id: %d) is disabled', + $sourceIdentifier, + (int) $account->getId(), + )); + + return null; + } + + /** @var EarnActionLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $sourceIdentifier, $rulesBreakdown, $expiresAt): ?EarnActionLoyaltyTransactionInterface { + $awarding = new AwardingPoints($account, $points, $expiresAt, null, $sourceIdentifier, $rulesBreakdown); + $this->eventDispatcher->dispatch($awarding); + if ($awarding->isCancelled() || $awarding->getPoints() <= 0) { + return null; + } + + $transaction = new EarnActionLoyaltyTransaction(); + $transaction->setSourceIdentifier($sourceIdentifier); + $transaction->setRulesBreakdown($rulesBreakdown); + + $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt()); + + $this->evaluateTier($account); + + $postCommitEvents[] = new PointsEarned($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function earnReferral( + LoyaltyAccountInterface $account, + int $points, + ReferralInterface $referral, + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnReferralLoyaltyTransactionInterface { + Assert::greaterThan($points, 0); + + /** @var EarnReferralLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $referral, $expiresAt): ?EarnReferralLoyaltyTransactionInterface { + $awarding = new AwardingPoints($account, $points, $expiresAt, null, null, []); + $this->eventDispatcher->dispatch($awarding); + if ($awarding->isCancelled() || $awarding->getPoints() <= 0) { + return null; + } + + $transaction = new EarnReferralLoyaltyTransaction(); + $transaction->setReferral($referral); + + $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt()); + + $this->evaluateTier($account); + + $postCommitEvents[] = new PointsEarned($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function redeem(OrderInterface $order, int $points): ?RedeemLoyaltyTransactionInterface + { + Assert::greaterThan($points, 0); + + $account = $this->accountFromOrder($order); + if (null === $account) { + throw new AccountNotFoundException(sprintf( + 'No loyalty account can be resolved for order %s', + (string) $order->getNumber(), + )); + } + + /** @var RedeemLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points): ?RedeemLoyaltyTransactionInterface { + if (null !== $this->transactionRepository->findRedeemTransaction($order)) { + $this->logger->info(sprintf( + '[Loyalty] Order %s already has a redemption; skipping duplicate debit', + (string) $order->getNumber(), + )); + + return null; + } + + if (!$account->isEnabled()) { + throw new LedgerConflictException(sprintf( + 'The loyalty account (id: %d) is disabled and cannot redeem points', + (int) $account->getId(), + )); + } + + if ($account->getBalance() < $points) { + throw InsufficientBalanceException::create($account, $points); + } + + $redeeming = new RedeemingPoints($account, $points, $order); + $this->eventDispatcher->dispatch($redeeming); + if ($redeeming->isCancelled()) { + throw new LedgerConflictException('The redemption was cancelled by a listener'); + } + + $transaction = new RedeemLoyaltyTransaction(); + $transaction->setOrder($order); + + $this->debit($account, $transaction, $points); + + $postCommitEvents[] = new PointsRedeemed($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function rollbackRedeem(OrderInterface $order): ?RedeemRollbackLoyaltyTransactionInterface + { + $redeem = $this->transactionRepository->findRedeemTransaction($order); + if (null === $redeem) { + return null; + } + + $account = $redeem->getAccount(); + Assert::notNull($account); + + /** @var RedeemRollbackLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($redeem): ?RedeemRollbackLoyaltyTransactionInterface { + if ($this->transactionRepository->hasRollback($redeem)) { + $this->logger->info(sprintf( + '[Loyalty] The redemption (id: %d) was already rolled back; skipping', + (int) $redeem->getId(), + )); + + return null; + } + + $transaction = new RedeemRollbackLoyaltyTransaction(); + $transaction->setRedeem($redeem); + + $this->credit( + $account, + $transaction, + abs($redeem->getPoints()), + $this->resolveRollbackExpiry($account, $redeem), + countsTowardLifetimeEarned: false, + ); + + $postCommitEvents[] = new RedemptionRolledBack($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function expire(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): array + { + $now ??= new \DateTimeImmutable(); + + /** @var list $transactions */ + $transactions = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($now): array { + $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account)); + + $transactions = []; + foreach ($replay->lots as $lotState) { + $expiresAt = $lotState->lot->getExpiresAt(); + if (null === $expiresAt || $expiresAt > $now || $lotState->isClosedByExpiration()) { + continue; + } + + $expiring = new ExpiringPoints($account, $lotState->lot, $lotState->getRemaining()); + $this->eventDispatcher->dispatch($expiring); + if ($expiring->isCancelled()) { + continue; + } + + $transaction = new ExpireLoyaltyTransaction(); + $transaction->setLot($lotState->lot); + + // Zero-point entries close fully consumed lots so the daily selection stays exact + $this->debit($account, $transaction, $lotState->getRemaining(), occurredAt: $now); + + $postCommitEvents[] = new PointsExpired($transaction); + $transactions[] = $transaction; + } + + return $transactions; + }, + ) ?? []; + + return $transactions; + } + + public function clawback(OrderInterface $order, int $points): ?ClawbackLoyaltyTransactionInterface + { + Assert::greaterThan($points, 0); + + $earn = $this->transactionRepository->findEarnOrderTransaction($order); + if (null === $earn) { + return null; + } + + // A lookup-first guard keeps idempotent replays from tripping the unique constraint + // (which would reset the entity manager mid-request); the constraint remains the + // concurrency backstop + if (null !== $this->transactionRepository->findClawbackForEarn($earn)) { + return null; + } + + $account = $earn->getAccount(); + Assert::notNull($account); + + /** @var ClawbackLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $earn): ?ClawbackLoyaltyTransactionInterface { + $clawingBack = new ClawingBackPoints($account, $points, $order, $earn); + $this->eventDispatcher->dispatch($clawingBack); + if ($clawingBack->isCancelled()) { + return null; + } + + $points = $clawingBack->getPoints(); + + $channel = $account->getChannel(); + Assert::notNull($channel); + $program = $this->programProvider->getByChannel($channel); + if (LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO === $program->getClawbackPolicy()) { + // Reduce the debit at write time so the balance lands at exactly zero; the + // ledger entry records what was actually debited + $points = min($points, max(0, $account->getBalance())); + } + + $transaction = new ClawbackLoyaltyTransaction(); + $transaction->setOrder($order); + $transaction->setEarn($earn); + + $this->debit($account, $transaction, $points); + + $postCommitEvents[] = new PointsClawedBack($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function clawbackCredit(CreditLoyaltyTransactionInterface $earn, ?OrderInterface $order = null): ?ClawbackLoyaltyTransactionInterface + { + $account = $earn->getAccount(); + Assert::notNull($account); + + $points = $earn->getPoints(); + if ($points <= 0) { + return null; + } + + if (null !== $this->transactionRepository->findClawbackForEarn($earn)) { + return null; + } + + /** @var ClawbackLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $earn): ?ClawbackLoyaltyTransactionInterface { + $clawingBack = new ClawingBackPoints($account, $points, $order, $earn); + $this->eventDispatcher->dispatch($clawingBack); + if ($clawingBack->isCancelled()) { + return null; + } + + $points = $clawingBack->getPoints(); + + $channel = $account->getChannel(); + Assert::notNull($channel); + $program = $this->programProvider->getByChannel($channel); + if (LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO === $program->getClawbackPolicy()) { + $points = min($points, max(0, $account->getBalance())); + } + + $transaction = new ClawbackLoyaltyTransaction(); + $transaction->setOrder($order); + $transaction->setEarn($earn); + + $this->debit($account, $transaction, $points); + + $postCommitEvents[] = new PointsClawedBack($transaction); + + return $transaction; + }, + ); + + return $transaction; + } + + public function manualCredit( + LoyaltyAccountInterface $account, + int $points, + string $reason, + string $note, + ?AdminUserInterface $adminUser = null, + ): ManualCreditLoyaltyTransactionInterface { + Assert::greaterThan($points, 0); + Assert::stringNotEmpty($note); + + /** @var ManualCreditLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $reason, $note, $adminUser): ManualCreditLoyaltyTransactionInterface { + $transaction = new ManualCreditLoyaltyTransaction(); + $transaction->setReason($reason); + $transaction->setNote($note); + $transaction->setAdminUser($adminUser); + + $this->credit($account, $transaction, $points, null); + + $this->evaluateTier($account); + + $postCommitEvents[] = new ManualAdjustment($transaction); + + return $transaction; + }, + ); + + Assert::notNull($transaction); + + return $transaction; + } + + public function manualDebit( + LoyaltyAccountInterface $account, + int $points, + string $reason, + string $note, + ?AdminUserInterface $adminUser = null, + ): ManualDebitLoyaltyTransactionInterface { + Assert::greaterThan($points, 0); + Assert::stringNotEmpty($note); + + /** @var ManualDebitLoyaltyTransactionInterface|null $transaction */ + $transaction = $this->transactional( + $account, + function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $reason, $note, $adminUser): ManualDebitLoyaltyTransactionInterface { + $transaction = new ManualDebitLoyaltyTransaction(); + $transaction->setReason($reason); + $transaction->setNote($note); + $transaction->setAdminUser($adminUser); + + $this->debit($account, $transaction, $points); + + $postCommitEvents[] = new ManualAdjustment($transaction); + + return $transaction; + }, + ); + + Assert::notNull($transaction); + + return $transaction; + } + + /** + * Runs the given callback with the account row locked (pessimistic write) inside a + * transaction, then dispatches the collected post-commit events. A unique constraint + * violation is an idempotent no-op by design; any plugin exception leaves the (closed) + * entity manager reset so the surrounding request stays usable. + * + * @param callable(LoyaltyAccountInterface, array): mixed $callback + */ + private function transactional(LoyaltyAccountInterface $account, callable $callback): mixed + { + $accountId = $account->getId(); + Assert::notNull($accountId, 'The loyalty account must be persisted before writing ledger entries'); + + /** @var array $postCommitEvents */ + $postCommitEvents = []; + + try { + $result = $this->entityManager->wrapInTransaction( + function () use ($accountId, $callback, &$postCommitEvents): mixed { + $account = $this->entityManager + ->getRepository($this->accountClass) + ->find($accountId, LockMode::PESSIMISTIC_WRITE) + ; + if (!$account instanceof LoyaltyAccountInterface) { + throw new AccountNotFoundException(sprintf('The loyalty account (id: %s) no longer exists', (string) $accountId)); + } + + return $callback($account, $postCommitEvents); + }, + ); + } catch (UniqueConstraintViolationException $e) { + // The write already happened (event redelivery, concurrent request) — a no-op by design + $this->managerRegistry->resetManager(); + $this->logger->info(sprintf('[Loyalty] Idempotent no-op for account (id: %d): %s', $accountId, $e->getMessage())); + + return null; + } catch (ExceptionInterface $e) { + $this->managerRegistry->resetManager(); + + throw $e; + } + + foreach ($postCommitEvents as $event) { + $this->eventDispatcher->dispatch($event); + } + + return $result; + } + + /** + * The qualification bases derive their metrics from ledger queries, so the pending credit + * must be flushed (still inside the surrounding transaction) before the tier evaluator + * runs — otherwise the earn that crosses a threshold would not upgrade until the nightly + * reconciliation. + */ + private function evaluateTier(LoyaltyAccountInterface $account): void + { + $this->entityManager->flush(); + $this->tierEvaluator->evaluate($account); + } + + private function credit( + LoyaltyAccountInterface $account, + CreditLoyaltyTransaction $transaction, + int $points, + ?\DateTimeImmutable $expiresAt, + bool $countsTowardLifetimeEarned = true, + ): void { + $transaction->setAccount($account); + $transaction->setPoints($points); + $transaction->setExpiresAt($expiresAt); + $this->entityManager->persist($transaction); + + $account->setBalance($account->getBalance() + $points); + if ($countsTowardLifetimeEarned) { + $account->setLifetimeEarned($account->getLifetimeEarned() + $points); + } + } + + private function debit( + LoyaltyAccountInterface $account, + DebitLoyaltyTransaction $transaction, + int $points, + ?\DateTimeImmutable $occurredAt = null, + ): void { + $transaction->setAccount($account); + $transaction->setPoints(-$points); + if (null !== $occurredAt) { + $transaction->setOccurredAt($occurredAt); + } + $this->entityManager->persist($transaction); + + $account->setBalance($account->getBalance() - $points); + } + + /** + * The new lot restores the redeemed points carrying the earliest surviving expiry of the + * lots the replay attributes to the rolled-back debit (simplification accepted by design). + */ + private function resolveRollbackExpiry(LoyaltyAccountInterface $account, RedeemLoyaltyTransactionInterface $redeem): ?\DateTimeImmutable + { + $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account)); + $now = new \DateTimeImmutable(); + + $earliest = null; + foreach ($replay->lots as $lotState) { + foreach ($lotState->getConsumptions() as $consumption) { + if ($consumption->debit !== $redeem) { + continue; + } + + $expiresAt = $lotState->lot->getExpiresAt(); + if (null === $expiresAt || $expiresAt <= $now) { + continue; + } + + if (null === $earliest || $expiresAt < $earliest) { + $earliest = $expiresAt; + } + } + } + + return $earliest; + } + + private function accountFromOrder(OrderInterface $order): ?LoyaltyAccountInterface + { + $customer = $order->getCustomer(); + $channel = $order->getChannel(); + + if (!$customer instanceof CustomerInterface || null === $channel) { + return null; + } + + return $this->accountProvider->getByCustomerAndChannel($customer, $channel); + } +} diff --git a/src/Ledger/LoyaltyLedgerInterface.php b/src/Ledger/LoyaltyLedgerInterface.php new file mode 100644 index 0000000..6dd70ca --- /dev/null +++ b/src/Ledger/LoyaltyLedgerInterface.php @@ -0,0 +1,117 @@ + $rulesBreakdown + */ + public function earnOrder( + OrderInterface $order, + int $points, + array $rulesBreakdown = [], + ?int $basisAmount = null, + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnOrderLoyaltyTransactionInterface; + + /** + * @param array $rulesBreakdown + */ + public function earnAction( + LoyaltyAccountInterface $account, + int $points, + string $sourceIdentifier, + array $rulesBreakdown = [], + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnActionLoyaltyTransactionInterface; + + /** + * Debits the applied points when the order completes checkout. The balance and the + * account's enabled state are re-validated inside the account lock. + * + * @throws InsufficientBalanceException if the balance no longer covers the points + * @throws LedgerConflictException if the account is disabled or a listener cancelled the redemption + */ + /** + * Credits referral reward points, idempotent per (account, referral). + */ + public function earnReferral( + LoyaltyAccountInterface $account, + int $points, + ReferralInterface $referral, + ?\DateTimeImmutable $expiresAt = null, + ): ?EarnReferralLoyaltyTransactionInterface; + + public function redeem(OrderInterface $order, int $points): ?RedeemLoyaltyTransactionInterface; + + /** + * Restores the points of the order's redemption as a new lot carrying the earliest + * surviving expiry of the lots the replay attributes to the rolled-back debit. + */ + public function rollbackRedeem(OrderInterface $order): ?RedeemRollbackLoyaltyTransactionInterface; + + /** + * Writes one expiration entry per expired open lot — including zero-point entries closing + * fully consumed lots. Runs for disabled accounts too, so the liability doesn't freeze. + * + * @return list + */ + public function expire(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): array; + + /** + * Debits the points earned for the order (looked up via its earn transaction; no-op if + * none). Public extension point for project-level (partial) refund integrations. + */ + public function clawback(OrderInterface $order, int $points): ?ClawbackLoyaltyTransactionInterface; + + /** + * Claws back an arbitrary credit (referral rewards, custom credits) — idempotent per + * credit via the (type, earn_id) unique constraint. The optional order records what + * caused the clawback. + */ + public function clawbackCredit(CreditLoyaltyTransactionInterface $earn, ?OrderInterface $order = null): ?ClawbackLoyaltyTransactionInterface; + + public function manualCredit( + LoyaltyAccountInterface $account, + int $points, + string $reason, + string $note, + ?AdminUserInterface $adminUser = null, + ): ManualCreditLoyaltyTransactionInterface; + + public function manualDebit( + LoyaltyAccountInterface $account, + int $points, + string $reason, + string $note, + ?AdminUserInterface $adminUser = null, + ): ManualDebitLoyaltyTransactionInterface; +} diff --git a/src/Ledger/ReplayResult.php b/src/Ledger/ReplayResult.php new file mode 100644 index 0000000..a40f2f0 --- /dev/null +++ b/src/Ledger/ReplayResult.php @@ -0,0 +1,47 @@ + $lots in the order the lots were opened + * @param list $expirations + * @param int $balance the signed sum of all replayed transactions + * @param int $deficit unserved debit points carried at the end of the replay (only under + * the allow_negative clawback policy or when debits skipped expired lots) + * @param list $anomalies human-readable descriptions of ledger inconsistencies + * encountered during replay (never auto-fixed) + */ + public function __construct( + public readonly array $lots, + public readonly array $expirations, + public readonly int $balance, + public readonly int $deficit, + public readonly array $anomalies, + ) { + } + + public function getLotState(CreditLoyaltyTransactionInterface $lot): ?LotState + { + foreach ($this->lots as $lotState) { + if ($lotState->lot === $lot) { + return $lotState; + } + } + + return null; + } + + /** + * @return list + */ + public function getOpenLots(): array + { + return array_values(array_filter($this->lots, static fn (LotState $lotState): bool => $lotState->isOpen())); + } +} diff --git a/src/LoyaltyAdjustmentTypes.php b/src/LoyaltyAdjustmentTypes.php new file mode 100644 index 0000000..933786d --- /dev/null +++ b/src/LoyaltyAdjustmentTypes.php @@ -0,0 +1,18 @@ +entityManager->find($this->orderClass, $message->orderId); + if (!$order instanceof OrderInterface) { + $this->logger->info(sprintf('[Loyalty] Order %d no longer exists; nothing to award', $message->orderId)); + + return; + } + + $customer = $order->getCustomer(); + $channel = $order->getChannel(); + if (!$customer instanceof CustomerInterface || null === $channel) { + // Guest checkout — guests earn nothing at order time (retroactive claim on registration) + return; + } + + $program = $this->programProvider->getByChannel($channel); + if (!$this->awardMomentReached($order, $program)) { + return; + } + + // Referral qualification shares the award moment; it decides once per referral and is + // independent of whether any earning rules exist + $this->referralQualifier->qualify($order); + + $rules = $this->ruleRepository->findForEvaluation($channel, EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE); + if ([] === $rules) { + return; + } + + $account = $this->accountProvider->getByCustomerAndChannel($customer, $channel); + if (!$account->isEnabled()) { + return; + } + + $basis = $this->basisCalculator->calculate($order, $program); + + $context = new EarningContext( + channel: $channel, + customer: $customer, + account: $account, + order: $order, + itemAmounts: $basis->itemAmounts, + extraAmount: $basis->extraAmount, + ); + + $result = $this->evaluator->evaluate($rules, $context, $program); + + $this->dryRunLogger->log($result, $account, $order); + + if ($result->points <= 0) { + return; + } + + $this->ledger->earnOrder( + $order, + $result->points, + $result->rulesBreakdown, + $basis->getTotal(), + self::expiresAt($program), + ); + } + + private function awardMomentReached(OrderInterface $order, LoyaltyProgramInterface $program): bool + { + if (LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED === $program->getAwardOrderPointsAt()) { + return OrderInterface::STATE_FULFILLED === $order->getState(); + } + + // Handles partial payments: award only when the whole order is paid + return OrderPaymentStates::STATE_PAID === $order->getPaymentState(); + } + + private static function expiresAt(LoyaltyProgramInterface $program): ?\DateTimeImmutable + { + $days = $program->getPointsExpiryDays(); + + return null === $days ? null : new \DateTimeImmutable(sprintf('+%d days', $days)); + } +} diff --git a/src/Message/Handler/ClaimPastOrderPointsHandler.php b/src/Message/Handler/ClaimPastOrderPointsHandler.php new file mode 100644 index 0000000..311573a --- /dev/null +++ b/src/Message/Handler/ClaimPastOrderPointsHandler.php @@ -0,0 +1,91 @@ +entityManager->find($this->customerClass, $message->customerId); + $channel = $this->entityManager->find($this->channelClass, $message->channelId); + + if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) { + return; + } + + $program = $this->programProvider->getByChannel($channel); + if (!$program->isRetroactiveGuestPoints()) { + return; + } + + foreach ($this->eligibleOrderIds($customer, $channel, $program) as $orderId) { + $this->messageBus->dispatch( + new Envelope(new AwardOrderPoints($orderId), [new DispatchAfterCurrentBusStamp()]), + ); + } + } + + /** + * @return list + */ + private function eligibleOrderIds( + CustomerInterface $customer, + ChannelInterface $channel, + LoyaltyProgramInterface $program, + ): array { + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('o.id') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.channel = :channel') + ->setParameter('customer', $customer) + ->setParameter('channel', $channel) + ; + + if (LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED === $program->getAwardOrderPointsAt()) { + $queryBuilder->andWhere('o.state = :state')->setParameter('state', OrderInterface::STATE_FULFILLED); + } else { + $queryBuilder->andWhere('o.paymentState = :paymentState')->setParameter('paymentState', OrderPaymentStates::STATE_PAID); + } + + /** @var list $rows */ + $rows = $queryBuilder->getQuery()->getScalarResult(); + + return array_map(static fn (array $row): int => (int) $row['id'], $rows); + } +} diff --git a/src/Model/ClawbackLoyaltyTransaction.php b/src/Model/ClawbackLoyaltyTransaction.php new file mode 100644 index 0000000..db9109b --- /dev/null +++ b/src/Model/ClawbackLoyaltyTransaction.php @@ -0,0 +1,39 @@ +order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getEarn(): ?CreditLoyaltyTransactionInterface + { + return $this->earn; + } + + public function setEarn(?CreditLoyaltyTransactionInterface $earn): void + { + $this->earn = $earn; + } + + public static function getDiscriminator(): string + { + return 'clawback'; + } +} diff --git a/src/Model/ClawbackLoyaltyTransactionInterface.php b/src/Model/ClawbackLoyaltyTransactionInterface.php new file mode 100644 index 0000000..1a13686 --- /dev/null +++ b/src/Model/ClawbackLoyaltyTransactionInterface.php @@ -0,0 +1,23 @@ +expiresAt; + } + + public function setExpiresAt(?\DateTimeImmutable $expiresAt): void + { + $this->expiresAt = $expiresAt; + } +} diff --git a/src/Model/CreditLoyaltyTransactionInterface.php b/src/Model/CreditLoyaltyTransactionInterface.php new file mode 100644 index 0000000..5b6630e --- /dev/null +++ b/src/Model/CreditLoyaltyTransactionInterface.php @@ -0,0 +1,16 @@ +|null */ + protected ?array $details = []; + + protected \DateTimeImmutable $createdAt; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getRule(): ?EarningRuleInterface + { + return $this->rule; + } + + public function setRule(?EarningRuleInterface $rule): void + { + $this->rule = $rule; + } + + public function getAccount(): ?LoyaltyAccountInterface + { + return $this->account; + } + + public function setAccount(?LoyaltyAccountInterface $account): void + { + $this->account = $account; + } + + public function getOrder(): ?OrderInterface + { + return $this->order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getPoints(): int + { + return $this->points; + } + + public function setPoints(int $points): void + { + $this->points = $points; + } + + public function getDetails(): array + { + return $this->details ?? []; + } + + public function setDetails(array $details): void + { + $this->details = $details; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Model/DryRunResultInterface.php b/src/Model/DryRunResultInterface.php new file mode 100644 index 0000000..7829863 --- /dev/null +++ b/src/Model/DryRunResultInterface.php @@ -0,0 +1,43 @@ + + */ + public function getDetails(): array; + + /** + * @param array $details + */ + public function setDetails(array $details): void; + + public function getCreatedAt(): \DateTimeImmutable; +} diff --git a/src/Model/EarnActionLoyaltyTransaction.php b/src/Model/EarnActionLoyaltyTransaction.php new file mode 100644 index 0000000..7ca09fb --- /dev/null +++ b/src/Model/EarnActionLoyaltyTransaction.php @@ -0,0 +1,27 @@ +sourceIdentifier; + } + + public function setSourceIdentifier(?string $sourceIdentifier): void + { + $this->sourceIdentifier = $sourceIdentifier; + } + + public static function getDiscriminator(): string + { + return 'earn_action'; + } +} diff --git a/src/Model/EarnActionLoyaltyTransactionInterface.php b/src/Model/EarnActionLoyaltyTransactionInterface.php new file mode 100644 index 0000000..fac8660 --- /dev/null +++ b/src/Model/EarnActionLoyaltyTransactionInterface.php @@ -0,0 +1,17 @@ +order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public function getBasisAmount(): ?int + { + return $this->basisAmount; + } + + public function setBasisAmount(?int $basisAmount): void + { + $this->basisAmount = $basisAmount; + } + + public static function getDiscriminator(): string + { + return 'earn_order'; + } +} diff --git a/src/Model/EarnOrderLoyaltyTransactionInterface.php b/src/Model/EarnOrderLoyaltyTransactionInterface.php new file mode 100644 index 0000000..984e692 --- /dev/null +++ b/src/Model/EarnOrderLoyaltyTransactionInterface.php @@ -0,0 +1,25 @@ +referral; + } + + public function setReferral(?ReferralInterface $referral): void + { + $this->referral = $referral; + } + + public static function getDiscriminator(): string + { + return 'earn_referral'; + } +} diff --git a/src/Model/EarnReferralLoyaltyTransactionInterface.php b/src/Model/EarnReferralLoyaltyTransactionInterface.php new file mode 100644 index 0000000..5f42624 --- /dev/null +++ b/src/Model/EarnReferralLoyaltyTransactionInterface.php @@ -0,0 +1,12 @@ +|null */ + protected ?array $scopeConfiguration = []; + + protected string $conditionsMatch = self::CONDITIONS_MATCH_ALL; + + /** @var Collection */ + protected Collection $conditions; + + protected ?string $amountType = null; + + /** @var array|null */ + protected ?array $amountConfiguration = []; + + protected ?\DateTimeImmutable $startsAt = null; + + protected ?\DateTimeImmutable $endsAt = null; + + protected bool $stackable = true; + + public function __construct() + { + $this->conditions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function setChannel(?ChannelInterface $channel): void + { + $this->channel = $channel; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function isDryRun(): bool + { + return $this->dryRun; + } + + public function setDryRun(bool $dryRun): void + { + $this->dryRun = $dryRun; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): void + { + $this->priority = $priority; + } + + public function getTrigger(): string + { + return $this->trigger; + } + + public function setTrigger(string $trigger): void + { + $this->trigger = $trigger; + } + + public function getScope(): string + { + return $this->scope; + } + + public function setScope(string $scope): void + { + $this->scope = $scope; + } + + public function getScopeConfiguration(): array + { + return $this->scopeConfiguration ?? []; + } + + public function setScopeConfiguration(array $scopeConfiguration): void + { + $this->scopeConfiguration = $scopeConfiguration; + } + + public function getConditionsMatch(): string + { + return $this->conditionsMatch; + } + + public function setConditionsMatch(string $conditionsMatch): void + { + $this->conditionsMatch = $conditionsMatch; + } + + public function getConditions(): Collection + { + return $this->conditions; + } + + public function addCondition(EarningRuleConditionInterface $condition): void + { + if (!$this->hasCondition($condition)) { + $this->conditions->add($condition); + $condition->setRule($this); + } + } + + public function removeCondition(EarningRuleConditionInterface $condition): void + { + if ($this->hasCondition($condition)) { + $this->conditions->removeElement($condition); + $condition->setRule(null); + } + } + + public function hasCondition(EarningRuleConditionInterface $condition): bool + { + return $this->conditions->contains($condition); + } + + public function getAmountType(): ?string + { + return $this->amountType; + } + + public function setAmountType(?string $amountType): void + { + $this->amountType = $amountType; + } + + public function getAmountConfiguration(): array + { + return $this->amountConfiguration ?? []; + } + + public function setAmountConfiguration(array $amountConfiguration): void + { + $this->amountConfiguration = $amountConfiguration; + } + + public function getStartsAt(): ?\DateTimeImmutable + { + return $this->startsAt; + } + + public function setStartsAt(?\DateTimeImmutable $startsAt): void + { + $this->startsAt = $startsAt; + } + + public function getEndsAt(): ?\DateTimeImmutable + { + return $this->endsAt; + } + + public function setEndsAt(?\DateTimeImmutable $endsAt): void + { + $this->endsAt = $endsAt; + } + + public function isStackable(): bool + { + return $this->stackable; + } + + public function setStackable(bool $stackable): void + { + $this->stackable = $stackable; + } +} diff --git a/src/Model/EarningRuleCondition.php b/src/Model/EarningRuleCondition.php new file mode 100644 index 0000000..c2f6550 --- /dev/null +++ b/src/Model/EarningRuleCondition.php @@ -0,0 +1,52 @@ +|null */ + protected ?array $configuration = []; + + public function getId(): ?int + { + return $this->id; + } + + public function getRule(): ?EarningRuleInterface + { + return $this->rule; + } + + public function setRule(?EarningRuleInterface $rule): void + { + $this->rule = $rule; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): void + { + $this->type = $type; + } + + public function getConfiguration(): array + { + return $this->configuration ?? []; + } + + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } +} diff --git a/src/Model/EarningRuleConditionInterface.php b/src/Model/EarningRuleConditionInterface.php new file mode 100644 index 0000000..402c04c --- /dev/null +++ b/src/Model/EarningRuleConditionInterface.php @@ -0,0 +1,33 @@ + + */ + public function getConfiguration(): array; + + /** + * @param array $configuration + */ + public function setConfiguration(array $configuration): void; +} diff --git a/src/Model/EarningRuleInterface.php b/src/Model/EarningRuleInterface.php new file mode 100644 index 0000000..17c1892 --- /dev/null +++ b/src/Model/EarningRuleInterface.php @@ -0,0 +1,115 @@ + + */ + public function getScopeConfiguration(): array; + + /** + * @param array $scopeConfiguration + */ + public function setScopeConfiguration(array $scopeConfiguration): void; + + public function getConditionsMatch(): string; + + public function setConditionsMatch(string $conditionsMatch): void; + + /** + * @return Collection + */ + public function getConditions(): Collection; + + public function addCondition(EarningRuleConditionInterface $condition): void; + + public function removeCondition(EarningRuleConditionInterface $condition): void; + + public function hasCondition(EarningRuleConditionInterface $condition): bool; + + public function getAmountType(): ?string; + + public function setAmountType(?string $amountType): void; + + /** + * @return array + */ + public function getAmountConfiguration(): array; + + /** + * @param array $amountConfiguration + */ + public function setAmountConfiguration(array $amountConfiguration): void; + + /** + * Start of the rule's active window, evaluated in the application's configured timezone. + */ + public function getStartsAt(): ?\DateTimeImmutable; + + public function setStartsAt(?\DateTimeImmutable $startsAt): void; + + public function getEndsAt(): ?\DateTimeImmutable; + + public function setEndsAt(?\DateTimeImmutable $endsAt): void; + + public function isStackable(): bool; + + public function setStackable(bool $stackable): void; +} diff --git a/src/Model/ExpireLoyaltyTransaction.php b/src/Model/ExpireLoyaltyTransaction.php new file mode 100644 index 0000000..81bcfcf --- /dev/null +++ b/src/Model/ExpireLoyaltyTransaction.php @@ -0,0 +1,25 @@ +lot; + } + + public function setLot(?CreditLoyaltyTransactionInterface $lot): void + { + $this->lot = $lot; + } + + public static function getDiscriminator(): string + { + return 'expire'; + } +} diff --git a/src/Model/ExpireLoyaltyTransactionInterface.php b/src/Model/ExpireLoyaltyTransactionInterface.php new file mode 100644 index 0000000..c302e46 --- /dev/null +++ b/src/Model/ExpireLoyaltyTransactionInterface.php @@ -0,0 +1,17 @@ +createdAt = new \DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCustomer(): ?CustomerInterface + { + return $this->customer; + } + + public function setCustomer(?CustomerInterface $customer): void + { + $this->customer = $customer; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function setChannel(?ChannelInterface $channel): void + { + $this->channel = $channel; + } + + public function getTier(): ?TierInterface + { + return $this->tier; + } + + public function setTier(?TierInterface $tier): void + { + $this->tier = $tier; + } + + public function getTierBelowThresholdSince(): ?\DateTimeImmutable + { + return $this->tierBelowThresholdSince; + } + + public function setTierBelowThresholdSince(?\DateTimeImmutable $tierBelowThresholdSince): void + { + $this->tierBelowThresholdSince = $tierBelowThresholdSince; + } + + public function getBalance(): int + { + return $this->balance; + } + + public function setBalance(int $balance): void + { + $this->balance = $balance; + } + + public function getLifetimeEarned(): int + { + return $this->lifetimeEarned; + } + + public function setLifetimeEarned(int $lifetimeEarned): void + { + $this->lifetimeEarned = $lifetimeEarned; + } + + public function getReferralCode(): ?string + { + return $this->referralCode; + } + + public function setReferralCode(?string $referralCode): void + { + $this->referralCode = $referralCode; + } + + public function getAnonymizedToken(): ?string + { + return $this->anonymizedToken; + } + + public function setAnonymizedToken(?string $anonymizedToken): void + { + $this->anonymizedToken = $anonymizedToken; + } +} diff --git a/src/Model/LoyaltyAccountInterface.php b/src/Model/LoyaltyAccountInterface.php new file mode 100644 index 0000000..b8279ba --- /dev/null +++ b/src/Model/LoyaltyAccountInterface.php @@ -0,0 +1,61 @@ +loyaltyPointsRequested; + } + + public function setLoyaltyPointsRequested(?int $loyaltyPointsRequested): void + { + $this->loyaltyPointsRequested = $loyaltyPointsRequested; + } +} diff --git a/src/Model/LoyaltyProgram.php b/src/Model/LoyaltyProgram.php new file mode 100644 index 0000000..7477f30 --- /dev/null +++ b/src/Model/LoyaltyProgram.php @@ -0,0 +1,285 @@ +id; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function setChannel(?ChannelInterface $channel): void + { + $this->channel = $channel; + } + + public function getAwardOrderPointsAt(): string + { + return $this->awardOrderPointsAt; + } + + public function setAwardOrderPointsAt(string $awardOrderPointsAt): void + { + $this->awardOrderPointsAt = $awardOrderPointsAt; + } + + public function getEarningBasis(): string + { + return $this->earningBasis; + } + + public function setEarningBasis(string $earningBasis): void + { + $this->earningBasis = $earningBasis; + } + + public function isIncludeTaxes(): bool + { + return $this->includeTaxes; + } + + public function setIncludeTaxes(bool $includeTaxes): void + { + $this->includeTaxes = $includeTaxes; + } + + public function getRounding(): string + { + return $this->rounding; + } + + public function setRounding(string $rounding): void + { + $this->rounding = $rounding; + } + + public function getRedemptionConversionPoints(): int + { + return $this->redemptionConversionPoints; + } + + public function setRedemptionConversionPoints(int $redemptionConversionPoints): void + { + $this->redemptionConversionPoints = $redemptionConversionPoints; + } + + public function getRedemptionConversionAmount(): int + { + return $this->redemptionConversionAmount; + } + + public function setRedemptionConversionAmount(int $redemptionConversionAmount): void + { + $this->redemptionConversionAmount = $redemptionConversionAmount; + } + + public function getMinRedeemPoints(): int + { + return $this->minRedeemPoints; + } + + public function setMinRedeemPoints(int $minRedeemPoints): void + { + $this->minRedeemPoints = $minRedeemPoints; + } + + public function getMaxRedeemPercentOfOrder(): int + { + return $this->maxRedeemPercentOfOrder; + } + + public function setMaxRedeemPercentOfOrder(int $maxRedeemPercentOfOrder): void + { + $this->maxRedeemPercentOfOrder = $maxRedeemPercentOfOrder; + } + + public function getPointsExpiryDays(): ?int + { + return $this->pointsExpiryDays; + } + + public function setPointsExpiryDays(?int $pointsExpiryDays): void + { + $this->pointsExpiryDays = $pointsExpiryDays; + } + + public function getClawbackPolicy(): string + { + return $this->clawbackPolicy; + } + + public function setClawbackPolicy(string $clawbackPolicy): void + { + $this->clawbackPolicy = $clawbackPolicy; + } + + public function isRetroactiveGuestPoints(): bool + { + return $this->retroactiveGuestPoints; + } + + public function setRetroactiveGuestPoints(bool $retroactiveGuestPoints): void + { + $this->retroactiveGuestPoints = $retroactiveGuestPoints; + } + + public function getLiabilityPoints(): ?int + { + return $this->liabilityPoints; + } + + public function setLiabilityPoints(?int $liabilityPoints): void + { + $this->liabilityPoints = $liabilityPoints; + } + + public function getLiabilityCalculatedAt(): ?\DateTimeImmutable + { + return $this->liabilityCalculatedAt; + } + + public function setLiabilityCalculatedAt(?\DateTimeImmutable $liabilityCalculatedAt): void + { + $this->liabilityCalculatedAt = $liabilityCalculatedAt; + } + + public function getTierEvaluationWindow(): string + { + return $this->tierEvaluationWindow; + } + + public function setTierEvaluationWindow(string $tierEvaluationWindow): void + { + $this->tierEvaluationWindow = $tierEvaluationWindow; + } + + public function getTierDowngradeGraceDays(): int + { + return $this->tierDowngradeGraceDays; + } + + public function setTierDowngradeGraceDays(int $tierDowngradeGraceDays): void + { + $this->tierDowngradeGraceDays = $tierDowngradeGraceDays; + } + + public function getReferralReferrerPoints(): int + { + return $this->referralReferrerPoints; + } + + public function setReferralReferrerPoints(int $referralReferrerPoints): void + { + $this->referralReferrerPoints = $referralReferrerPoints; + } + + public function getReferralRefereePoints(): int + { + return $this->referralRefereePoints; + } + + public function setReferralRefereePoints(int $referralRefereePoints): void + { + $this->referralRefereePoints = $referralRefereePoints; + } + + public function getReferralMinOrderTotal(): int + { + return $this->referralMinOrderTotal; + } + + public function setReferralMinOrderTotal(int $referralMinOrderTotal): void + { + $this->referralMinOrderTotal = $referralMinOrderTotal; + } + + public function getReferralPendingExpiryDays(): int + { + return $this->referralPendingExpiryDays; + } + + public function setReferralPendingExpiryDays(int $referralPendingExpiryDays): void + { + $this->referralPendingExpiryDays = $referralPendingExpiryDays; + } + + public function isShowEarnableOnProduct(): bool + { + return $this->showEarnableOnProduct; + } + + public function setShowEarnableOnProduct(bool $showEarnableOnProduct): void + { + $this->showEarnableOnProduct = $showEarnableOnProduct; + } + + public function isShowEarnableInCart(): bool + { + return $this->showEarnableInCart; + } + + public function setShowEarnableInCart(bool $showEarnableInCart): void + { + $this->showEarnableInCart = $showEarnableInCart; + } +} diff --git a/src/Model/LoyaltyProgramInterface.php b/src/Model/LoyaltyProgramInterface.php new file mode 100644 index 0000000..68d6be0 --- /dev/null +++ b/src/Model/LoyaltyProgramInterface.php @@ -0,0 +1,149 @@ + points) is deliberately not a program + * parameter: it is defined by earning rules. + */ +interface LoyaltyProgramInterface extends ResourceInterface, ChannelAwareInterface +{ + public const AWARD_ORDER_POINTS_AT_PAYMENT_PAID = 'payment_paid'; + + public const AWARD_ORDER_POINTS_AT_ORDER_FULFILLED = 'order_fulfilled'; + + public const EARNING_BASIS_ITEMS_TOTAL = 'items_total'; + + public const EARNING_BASIS_ORDER_TOTAL = 'order_total'; + + public const ROUNDING_FLOOR = 'floor'; + + public const ROUNDING_ROUND = 'round'; + + public const ROUNDING_CEIL = 'ceil'; + + public const CLAWBACK_POLICY_ALLOW_NEGATIVE = 'allow_negative'; + + public const CLAWBACK_POLICY_CLAMP_TO_ZERO = 'clamp_to_zero'; + + public const TIER_EVALUATION_WINDOW_CALENDAR_YEAR = 'calendar_year'; + + public const TIER_EVALUATION_WINDOW_ROLLING_12_MONTHS = 'rolling_12_months'; + + public const TIER_EVALUATION_WINDOW_LIFETIME = 'lifetime'; + + /** + * The single order-lifecycle moment the order pipeline fires. Deliberately one per program, + * never per rule: the one-earn-per-order idempotency constraint and the clawback lookup both + * depend on a single earn entry per order. + */ + public function getAwardOrderPointsAt(): string; + + public function setAwardOrderPointsAt(string $awardOrderPointsAt): void; + + public function getEarningBasis(): string; + + public function setEarningBasis(string $earningBasis): void; + + public function isIncludeTaxes(): bool; + + public function setIncludeTaxes(bool $includeTaxes): void; + + public function getRounding(): string; + + public function setRounding(string $rounding): void; + + /** + * Points -> currency when spending: getRedemptionConversionPoints() points are worth + * getRedemptionConversionAmount() minor units. + */ + public function getRedemptionConversionPoints(): int; + + public function setRedemptionConversionPoints(int $redemptionConversionPoints): void; + + public function getRedemptionConversionAmount(): int; + + public function setRedemptionConversionAmount(int $redemptionConversionAmount): void; + + public function getMinRedeemPoints(): int; + + public function setMinRedeemPoints(int $minRedeemPoints): void; + + /** + * Cap of the order items total coverable by points, in percent (0-100). + */ + public function getMaxRedeemPercentOfOrder(): int; + + public function setMaxRedeemPercentOfOrder(int $maxRedeemPercentOfOrder): void; + + /** + * Null means points never expire. + */ + public function getPointsExpiryDays(): ?int; + + public function setPointsExpiryDays(?int $pointsExpiryDays): void; + + public function getClawbackPolicy(): string; + + public function setClawbackPolicy(string $clawbackPolicy): void; + + /** + * Whether to award points for pre-registration guest orders when the guest registers. + */ + public function isRetroactiveGuestPoints(): bool; + + public function setRetroactiveGuestPoints(bool $retroactiveGuestPoints): void; + + public function getLiabilityPoints(): ?int; + + public function setLiabilityPoints(?int $liabilityPoints): void; + + public function getLiabilityCalculatedAt(): ?\DateTimeImmutable; + + public function setLiabilityCalculatedAt(?\DateTimeImmutable $liabilityCalculatedAt): void; + + public function getTierEvaluationWindow(): string; + + public function setTierEvaluationWindow(string $tierEvaluationWindow): void; + + public function getTierDowngradeGraceDays(): int; + + public function setTierDowngradeGraceDays(int $tierDowngradeGraceDays): void; + + public function getReferralReferrerPoints(): int; + + public function setReferralReferrerPoints(int $referralReferrerPoints): void; + + public function getReferralRefereePoints(): int; + + public function setReferralRefereePoints(int $referralRefereePoints): void; + + /** + * Minimum items total (in minor units of the channel base currency) of the referee's first + * order for a referral to qualify. + */ + public function getReferralMinOrderTotal(): int; + + public function setReferralMinOrderTotal(int $referralMinOrderTotal): void; + + public function getReferralPendingExpiryDays(): int; + + public function setReferralPendingExpiryDays(int $referralPendingExpiryDays): void; + + public function isShowEarnableOnProduct(): bool; + + public function setShowEarnableOnProduct(bool $showEarnableOnProduct): void; + + public function isShowEarnableInCart(): bool; + + public function setShowEarnableInCart(bool $showEarnableInCart): void; +} diff --git a/src/Model/LoyaltyTransaction.php b/src/Model/LoyaltyTransaction.php new file mode 100644 index 0000000..6545eda --- /dev/null +++ b/src/Model/LoyaltyTransaction.php @@ -0,0 +1,63 @@ +occurredAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccount(): ?LoyaltyAccountInterface + { + return $this->account; + } + + public function setAccount(?LoyaltyAccountInterface $account): void + { + $this->account = $account; + } + + public function getPoints(): int + { + return $this->points; + } + + public function setPoints(int $points): void + { + $this->points = $points; + } + + public function getOccurredAt(): \DateTimeImmutable + { + return $this->occurredAt; + } + + public function setOccurredAt(\DateTimeImmutable $occurredAt): void + { + $this->occurredAt = $occurredAt; + } + + /** + * The Doctrine discriminator value of this transaction type. Plugin-shipped types are + * declared in the XML mapping; custom types registered as Sylius resources are added to + * the discriminator map from this value. + */ + abstract public static function getDiscriminator(): string; +} diff --git a/src/Model/LoyaltyTransactionInterface.php b/src/Model/LoyaltyTransactionInterface.php new file mode 100644 index 0000000..4cdd22f --- /dev/null +++ b/src/Model/LoyaltyTransactionInterface.php @@ -0,0 +1,29 @@ +reason; + } + + public function setReason(?string $reason): void + { + $this->reason = $reason; + } + + public function getNote(): ?string + { + return $this->note; + } + + public function setNote(?string $note): void + { + $this->note = $note; + } + + public function getAdminUser(): ?AdminUserInterface + { + return $this->adminUser; + } + + public function setAdminUser(?AdminUserInterface $adminUser): void + { + $this->adminUser = $adminUser; + } +} diff --git a/src/Model/RedeemLoyaltyTransaction.php b/src/Model/RedeemLoyaltyTransaction.php new file mode 100644 index 0000000..78345b6 --- /dev/null +++ b/src/Model/RedeemLoyaltyTransaction.php @@ -0,0 +1,27 @@ +order; + } + + public function setOrder(?OrderInterface $order): void + { + $this->order = $order; + } + + public static function getDiscriminator(): string + { + return 'redeem'; + } +} diff --git a/src/Model/RedeemLoyaltyTransactionInterface.php b/src/Model/RedeemLoyaltyTransactionInterface.php new file mode 100644 index 0000000..5f3ce6f --- /dev/null +++ b/src/Model/RedeemLoyaltyTransactionInterface.php @@ -0,0 +1,18 @@ +redeem; + } + + public function setRedeem(?RedeemLoyaltyTransactionInterface $redeem): void + { + $this->redeem = $redeem; + } + + public static function getDiscriminator(): string + { + return 'redeem_rollback'; + } +} diff --git a/src/Model/RedeemRollbackLoyaltyTransactionInterface.php b/src/Model/RedeemRollbackLoyaltyTransactionInterface.php new file mode 100644 index 0000000..53eb8d7 --- /dev/null +++ b/src/Model/RedeemRollbackLoyaltyTransactionInterface.php @@ -0,0 +1,17 @@ +|null */ + protected ?array $fraudFlags = null; + + protected ?string $registrationIpHash = null; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getReferrerAccount(): ?LoyaltyAccountInterface + { + return $this->referrerAccount; + } + + public function setReferrerAccount(?LoyaltyAccountInterface $referrerAccount): void + { + $this->referrerAccount = $referrerAccount; + } + + public function getRefereeCustomer(): ?CustomerInterface + { + return $this->refereeCustomer; + } + + public function setRefereeCustomer(?CustomerInterface $refereeCustomer): void + { + $this->refereeCustomer = $refereeCustomer; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function setChannel(?ChannelInterface $channel): void + { + $this->channel = $channel; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(?string $code): void + { + $this->code = $code; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): void + { + $this->status = $status; + } + + public function getRefereeFirstOrder(): ?OrderInterface + { + return $this->refereeFirstOrder; + } + + public function setRefereeFirstOrder(?OrderInterface $refereeFirstOrder): void + { + $this->refereeFirstOrder = $refereeFirstOrder; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } + + public function getQualifiedAt(): ?\DateTimeImmutable + { + return $this->qualifiedAt; + } + + public function setQualifiedAt(?\DateTimeImmutable $qualifiedAt): void + { + $this->qualifiedAt = $qualifiedAt; + } + + public function getFraudFlags(): array + { + return $this->fraudFlags ?? []; + } + + public function setFraudFlags(array $fraudFlags): void + { + $this->fraudFlags = $fraudFlags; + } + + public function getRegistrationIpHash(): ?string + { + return $this->registrationIpHash; + } + + public function setRegistrationIpHash(?string $registrationIpHash): void + { + $this->registrationIpHash = $registrationIpHash; + } +} diff --git a/src/Model/ReferralInterface.php b/src/Model/ReferralInterface.php new file mode 100644 index 0000000..7070104 --- /dev/null +++ b/src/Model/ReferralInterface.php @@ -0,0 +1,78 @@ + + */ + public function getFraudFlags(): array; + + /** + * @param list $fraudFlags + */ + public function setFraudFlags(array $fraudFlags): void; + + /** + * The salted hash of the registration IP — present only when the opt-in IP fraud check is + * enabled; purged after 90 days. + */ + public function getRegistrationIpHash(): ?string; + + public function setRegistrationIpHash(?string $registrationIpHash): void; +} diff --git a/src/Model/RulesBreakdownAwareInterface.php b/src/Model/RulesBreakdownAwareInterface.php new file mode 100644 index 0000000..8145ce4 --- /dev/null +++ b/src/Model/RulesBreakdownAwareInterface.php @@ -0,0 +1,22 @@ + + */ + public function getRulesBreakdown(): array; + + /** + * @param array $rulesBreakdown + */ + public function setRulesBreakdown(array $rulesBreakdown): void; +} diff --git a/src/Model/RulesBreakdownAwareTrait.php b/src/Model/RulesBreakdownAwareTrait.php new file mode 100644 index 0000000..6b12fac --- /dev/null +++ b/src/Model/RulesBreakdownAwareTrait.php @@ -0,0 +1,32 @@ +|null + */ + protected ?array $rulesBreakdown = []; + + /** + * @return array + */ + public function getRulesBreakdown(): array + { + return $this->rulesBreakdown ?? []; + } + + /** + * @param array $rulesBreakdown + */ + public function setRulesBreakdown(array $rulesBreakdown): void + { + $this->rulesBreakdown = $rulesBreakdown; + } +} diff --git a/src/Model/Tier.php b/src/Model/Tier.php new file mode 100644 index 0000000..8c5b603 --- /dev/null +++ b/src/Model/Tier.php @@ -0,0 +1,147 @@ +initializeTranslationsCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(?string $code): void + { + $this->code = $code; + } + + public function getChannel(): ?ChannelInterface + { + return $this->channel; + } + + public function setChannel(?ChannelInterface $channel): void + { + $this->channel = $channel; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): void + { + $this->position = $position; + } + + public function getQualificationBasis(): string + { + return $this->qualificationBasis; + } + + public function setQualificationBasis(string $qualificationBasis): void + { + $this->qualificationBasis = $qualificationBasis; + } + + public function getThreshold(): int + { + return $this->threshold; + } + + public function setThreshold(int $threshold): void + { + $this->threshold = $threshold; + } + + public function getEarningMultiplier(): float + { + return $this->earningMultiplier; + } + + public function setEarningMultiplier(float $earningMultiplier): void + { + $this->earningMultiplier = $earningMultiplier; + } + + public function getColor(): ?string + { + return $this->color; + } + + public function setColor(?string $color): void + { + $this->color = $color; + } + + public function getBenefitsDescription(): ?string + { + $translation = $this->getTranslation(); + \assert($translation instanceof TierTranslationInterface); + + return $translation->getBenefitsDescription(); + } + + public function setBenefitsDescription(?string $benefitsDescription): void + { + $translation = $this->getTranslation(); + \assert($translation instanceof TierTranslationInterface); + + $translation->setBenefitsDescription($benefitsDescription); + } + + protected function createTranslation(): TranslationInterface + { + return new TierTranslation(); + } +} diff --git a/src/Model/TierInterface.php b/src/Model/TierInterface.php new file mode 100644 index 0000000..06d64c0 --- /dev/null +++ b/src/Model/TierInterface.php @@ -0,0 +1,61 @@ +id; + } + + public function getBenefitsDescription(): ?string + { + return $this->benefitsDescription; + } + + public function setBenefitsDescription(?string $benefitsDescription): void + { + $this->benefitsDescription = $benefitsDescription; + } +} diff --git a/src/Model/TierTranslationInterface.php b/src/Model/TierTranslationInterface.php new file mode 100644 index 0000000..9b7a1bb --- /dev/null +++ b/src/Model/TierTranslationInterface.php @@ -0,0 +1,17 @@ + $adjustmentFactory + */ + public function __construct( + private readonly LoyaltyAccountRepositoryInterface $accountRepository, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly PointsConverterInterface $pointsConverter, + private readonly AdjustmentFactoryInterface $adjustmentFactory, + private readonly ProportionalIntegerDistributorInterface $proportionalDistributor, + private readonly IntegerDistributorInterface $integerDistributor, + private readonly TranslatorInterface $translator, + ) { + } + + public function process(BaseOrderInterface $order): void + { + Assert::isInstanceOf($order, OrderInterface::class); + + if (OrderInterface::STATE_CART !== $order->getState()) { + return; + } + + $order->removeAdjustmentsRecursively(LoyaltyAdjustmentTypes::REDEMPTION); + + if (!$order instanceof LoyaltyOrderInterface) { + return; + } + + $requested = $order->getLoyaltyPointsRequested() ?? 0; + if ($requested <= 0) { + return; + } + + $customer = $order->getCustomer(); + $channel = $order->getChannel(); + if (!$customer instanceof CustomerInterface || null === $channel) { + return; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null === $account || !$account->isEnabled()) { + return; + } + + $program = $this->programProvider->getByChannel($channel); + + $applied = $this->appliedPoints($requested, $account->getBalance(), $order, $program); + if ($applied <= 0) { + return; + } + + $this->distribute($order, -$this->pointsConverter->amountFromPoints($applied, $program), $applied); + } + + private function appliedPoints(int $requested, int $balance, OrderInterface $order, LoyaltyProgramInterface $program): int + { + $capAmount = (int) floor($order->getItemsTotal() * $program->getMaxRedeemPercentOfOrder() / 100); + $capPoints = $this->pointsConverter->pointsFromAmount($capAmount, $program); + + return $this->pointsConverter->clampToCleanMultiple(min($requested, $balance, $capPoints), $program); + } + + /** + * Distributes the discount across the order items' units following Sylius' order-promotion + * pattern, which is what makes VAT calculate on the net amount natively. + */ + private function distribute(OrderInterface $order, int $discount, int $appliedPoints): void + { + $items = []; + $itemTotals = []; + foreach ($order->getItems() as $item) { + if (!$item instanceof OrderItemInterface) { + continue; + } + + $items[] = $item; + $itemTotals[] = $item->getTotal(); + } + + if ([] === $items) { + return; + } + + $label = $this->translator->trans('setono_sylius_loyalty.ui.points_redemption'); + + $itemShares = $this->proportionalDistributor->distribute($itemTotals, $discount); + foreach ($items as $index => $item) { + $itemShare = $itemShares[$index] ?? 0; + if (!is_int($itemShare) || 0 === $itemShare) { + continue; + } + + $unitShares = $this->integerDistributor->distribute($itemShare, $item->getQuantity()); + $unitIndex = 0; + foreach ($item->getUnits() as $unit) { + $amount = $unitShares[$unitIndex] ?? 0; + ++$unitIndex; + if (!is_int($amount) || 0 === $amount) { + continue; + } + + $adjustment = $this->adjustmentFactory->createWithData( + LoyaltyAdjustmentTypes::REDEMPTION, + $label, + $amount, + ); + $adjustment->setDetails([ + 'appliedPoints' => $appliedPoints, + ]); + + $unit->addAdjustment($adjustment); + } + } + } +} diff --git a/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php b/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php new file mode 100644 index 0000000..d164f00 --- /dev/null +++ b/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php @@ -0,0 +1,67 @@ + $configuration + */ + public function isEligible(PromotionSubjectInterface $subject, array $configuration): bool + { + if (!$subject instanceof OrderInterface) { + return false; + } + + $customer = $subject->getCustomer(); + $channel = $subject->getChannel(); + if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) { + return false; + } + + $requiredCode = $configuration['tier'] ?? null; + if (!is_string($requiredCode) || '' === $requiredCode) { + return false; + } + + $required = $this->tierRepository->findOneBy(['code' => $requiredCode, 'channel' => $channel]); + if (!$required instanceof TierInterface) { + return false; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null === $account || !$account->isEnabled()) { + return false; + } + + $tier = $account->getTier(); + if (null === $tier) { + return false; + } + + return $tier->getPosition() >= $required->getPosition(); + } +} diff --git a/src/Provider/Admin/DashboardStatsProvider.php b/src/Provider/Admin/DashboardStatsProvider.php new file mode 100644 index 0000000..aa1dc8a --- /dev/null +++ b/src/Provider/Admin/DashboardStatsProvider.php @@ -0,0 +1,116 @@ +entityManager->createQueryBuilder() + ->select('COUNT(a.id)') + ->from($this->accountClass, 'a') + ->getQuery() + ->getSingleScalarResult() + ; + + $earned = (int) $this->entityManager->createQueryBuilder() + ->select('COALESCE(SUM(c.points), 0)') + ->from(CreditLoyaltyTransaction::class, 'c') + ->andWhere('c.occurredAt >= :since') + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult() + ; + + $redeemed = (int) $this->entityManager->createQueryBuilder() + ->select('COALESCE(SUM(-r.points), 0)') + ->from(RedeemLoyaltyTransaction::class, 'r') + ->andWhere('r.occurredAt >= :since') + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult() + ; + + $earned90 = (int) $this->entityManager->createQueryBuilder() + ->select('COALESCE(SUM(c.points), 0)') + ->from(CreditLoyaltyTransaction::class, 'c') + ->andWhere('c.occurredAt >= :since') + ->andWhere('c NOT INSTANCE OF :rollback') + ->setParameter('since', $quarter) + ->setParameter('rollback', $this->entityManager->getClassMetadata(RedeemRollbackLoyaltyTransaction::class)) + ->getQuery() + ->getSingleScalarResult() + ; + + $redeemed90 = (int) $this->entityManager->createQueryBuilder() + ->select('COALESCE(SUM(-r.points), 0)') + ->from(RedeemLoyaltyTransaction::class, 'r') + ->andWhere('r.occurredAt >= :since') + ->setParameter('since', $quarter) + ->getQuery() + ->getSingleScalarResult() + ; + + $activeAccounts = (int) $this->entityManager->createQueryBuilder() + ->select('COUNT(DISTINCT IDENTITY(t.account))') + ->from($this->transactionClass, 't') + ->andWhere('t.occurredAt >= :since') + ->setParameter('since', $quarter) + ->getQuery() + ->getSingleScalarResult() + ; + + $liability = null; + $liabilityCalculatedAt = null; + /** @var list $programs */ + $programs = $this->entityManager->getRepository($this->programClass)->findAll(); + foreach ($programs as $program) { + if (null !== $program->getLiabilityPoints()) { + $liability = ($liability ?? 0) + $program->getLiabilityPoints(); + $calculatedAt = $program->getLiabilityCalculatedAt(); + if (null === $liabilityCalculatedAt || ($calculatedAt !== null && $calculatedAt < $liabilityCalculatedAt)) { + $liabilityCalculatedAt = $calculatedAt; + } + } + } + + return [ + 'accounts' => $accounts, + 'earnedLast30Days' => $earned, + 'redeemedLast30Days' => $redeemed, + 'activeAccounts90Days' => $activeAccounts, + 'redemptionRate90Days' => $earned90 > 0 ? round($redeemed90 / $earned90 * 100, 1) : null, + 'liabilityPoints' => $liability, + 'liabilityCalculatedAt' => $liabilityCalculatedAt, + ]; + } +} diff --git a/src/Provider/Admin/DashboardStatsProviderInterface.php b/src/Provider/Admin/DashboardStatsProviderInterface.php new file mode 100644 index 0000000..bbf7aec --- /dev/null +++ b/src/Provider/Admin/DashboardStatsProviderInterface.php @@ -0,0 +1,13 @@ + $accountFactory + */ + public function __construct( + private readonly LoyaltyAccountRepositoryInterface $accountRepository, + private readonly FactoryInterface $accountFactory, + private readonly ManagerRegistry $managerRegistry, + ) { + } + + public function getByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): LoyaltyAccountInterface + { + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null !== $account) { + return $account; + } + + $account = $this->accountFactory->createNew(); + Assert::isInstanceOf($account, LoyaltyAccountInterface::class); + $account->setCustomer($customer); + $account->setChannel($channel); + + $manager = $this->managerRegistry->getManagerForClass($account::class); + Assert::notNull($manager); + + try { + $manager->persist($account); + $manager->flush(); + } catch (UniqueConstraintViolationException) { + // Another process created the account concurrently. The entity manager is closed + // by the failed flush, so reset it and load the winning row. + $this->managerRegistry->resetManager(); + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + Assert::notNull($account); + } + + return $account; + } +} diff --git a/src/Provider/LoyaltyAccountProviderInterface.php b/src/Provider/LoyaltyAccountProviderInterface.php new file mode 100644 index 0000000..bd5da25 --- /dev/null +++ b/src/Provider/LoyaltyAccountProviderInterface.php @@ -0,0 +1,18 @@ + $programFactory + */ + public function __construct( + private readonly LoyaltyProgramRepositoryInterface $programRepository, + private readonly FactoryInterface $programFactory, + private readonly ManagerRegistry $managerRegistry, + ) { + } + + public function getByChannel(ChannelInterface $channel): LoyaltyProgramInterface + { + $program = $this->programRepository->findOneByChannel($channel); + if (null !== $program) { + return $program; + } + + $program = $this->programFactory->createNew(); + Assert::isInstanceOf($program, LoyaltyProgramInterface::class); + $program->setChannel($channel); + + $manager = $this->managerRegistry->getManagerForClass($program::class); + Assert::notNull($manager); + + try { + $manager->persist($program); + $manager->flush(); + } catch (UniqueConstraintViolationException) { + // Another process created the program concurrently. The entity manager is closed + // by the failed flush, so reset it and load the winning row. + $this->managerRegistry->resetManager(); + + $program = $this->programRepository->findOneByChannel($channel); + Assert::notNull($program); + } + + return $program; + } +} diff --git a/src/Provider/LoyaltyProgramProviderInterface.php b/src/Provider/LoyaltyProgramProviderInterface.php new file mode 100644 index 0000000..295b016 --- /dev/null +++ b/src/Provider/LoyaltyProgramProviderInterface.php @@ -0,0 +1,16 @@ + $presets preset step buttons: points and + * the resulting discount in minor units (the only place a currency equivalent of + * points is shown) + */ + public function __construct( + public readonly int $balance, + public readonly int $minRedeemPoints, + public readonly array $presets, + public readonly int $requestedPoints, + public readonly int $appliedPoints, + public readonly int $appliedAmount, + ) { + } + + public function canRedeem(): bool + { + return $this->balance >= $this->minRedeemPoints; + } + + public function isClamped(): bool + { + return $this->appliedPoints > 0 && $this->appliedPoints < $this->requestedPoints; + } +} diff --git a/src/Provider/Shop/CartRedemptionViewProvider.php b/src/Provider/Shop/CartRedemptionViewProvider.php new file mode 100644 index 0000000..cc4245e --- /dev/null +++ b/src/Provider/Shop/CartRedemptionViewProvider.php @@ -0,0 +1,101 @@ +getCustomer(); + $channel = $cart->getChannel(); + if (!$customer instanceof CustomerInterface || null === $channel) { + return null; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + if (null === $account || !$account->isEnabled()) { + return null; + } + + $program = $this->programProvider->getByChannel($channel); + $appliedPoints = $this->appliedPointsProvider->getAppliedPoints($cart); + + if ($account->getBalance() < $program->getMinRedeemPoints() && $appliedPoints <= 0) { + return null; + } + + return new CartRedemptionView( + $account->getBalance(), + $program->getMinRedeemPoints(), + $this->presets($cart, $account->getBalance(), $program), + $cart->getLoyaltyPointsRequested() ?? 0, + $appliedPoints, + $this->pointsConverter->amountFromPoints($appliedPoints, $program), + ); + } + + /** + * Up to three preset steps between the redemption minimum and the currently usable + * maximum, each a clean multiple of the conversion so it maps to a clean currency amount. + * + * @return list + */ + private function presets(OrderInterface $cart, int $balance, LoyaltyProgramInterface $program): array + { + $capPoints = $this->pointsConverter->pointsFromAmount( + (int) floor($cart->getItemsTotal() * $program->getMaxRedeemPercentOfOrder() / 100), + $program, + ); + + $maxUsable = $this->pointsConverter->clampToCleanMultiple(min($balance, $capPoints), $program); + + $pointsUnit = max(1, $program->getRedemptionConversionPoints()); + $minimum = $program->getMinRedeemPoints(); + $minimum += ($pointsUnit - $minimum % $pointsUnit) % $pointsUnit; + + $candidates = [ + $minimum, + $this->pointsConverter->clampToCleanMultiple(intdiv($maxUsable, 2), $program), + $this->pointsConverter->clampToCleanMultiple(intdiv($maxUsable * 3, 4), $program), + ]; + + $presets = []; + foreach ($candidates as $points) { + if ($points < $minimum || $points > $maxUsable || $points > $balance) { + continue; + } + + $presets[$points] = [ + 'points' => $points, + 'amount' => $this->pointsConverter->amountFromPoints($points, $program), + ]; + } + + ksort($presets); + + return array_values($presets); + } +} diff --git a/src/Provider/Shop/CartRedemptionViewProviderInterface.php b/src/Provider/Shop/CartRedemptionViewProviderInterface.php new file mode 100644 index 0000000..a9dbde8 --- /dev/null +++ b/src/Provider/Shop/CartRedemptionViewProviderInterface.php @@ -0,0 +1,16 @@ +threshold <= 0) { + return 0; + } + + return (int) min(100, floor($this->metric / $this->threshold * 100)); + } + + public function getRemaining(): int + { + return max(0, $this->threshold - $this->metric); + } +} diff --git a/src/Provider/Shop/TierProgressProvider.php b/src/Provider/Shop/TierProgressProvider.php new file mode 100644 index 0000000..1eaec3e --- /dev/null +++ b/src/Provider/Shop/TierProgressProvider.php @@ -0,0 +1,58 @@ +getChannel(); + if (!$channel instanceof ChannelInterface) { + return null; + } + + $tiers = $this->tierRepository->findQualifiable($channel); + if ([] === $tiers) { + return null; + } + + $current = $account->getTier(); + $currentPosition = null === $current ? \PHP_INT_MIN : $current->getPosition(); + + // The next tier is the lowest-positioned tier above the current one + $next = null; + foreach ($tiers as $tier) { + if ($tier->getPosition() > $currentPosition) { + $next = $tier; + } else { + break; + } + } + + if (null === $next) { + return new TierProgress($current, null, 0, 0, true); + } + + $window = $this->windowResolver->resolve($this->programProvider->getByChannel($channel)); + $metric = $this->basisRegistry->get($next->getQualificationBasis())->calculate($account, $window); + + return new TierProgress($current, $next, $metric, $next->getThreshold(), false); + } +} diff --git a/src/Provider/Shop/TierProgressProviderInterface.php b/src/Provider/Shop/TierProgressProviderInterface.php new file mode 100644 index 0000000..38af538 --- /dev/null +++ b/src/Provider/Shop/TierProgressProviderInterface.php @@ -0,0 +1,15 @@ +getAdjustmentsTotalRecursively(LoyaltyAdjustmentTypes::REDEMPTION); + if ($discount <= 0) { + return 0; + } + + $channel = $order->getChannel(); + if (null === $channel) { + return 0; + } + + return $this->pointsFromDiscount($discount, $this->programProvider->getByChannel($channel)); + } + + /** + * The discount is a clean multiple of the conversion amount by construction, so this is + * exact: discount / A * P. + */ + private function pointsFromDiscount(int $discount, LoyaltyProgramInterface $program): int + { + $amountUnit = max(1, $program->getRedemptionConversionAmount()); + + return intdiv($discount, $amountUnit) * max(1, $program->getRedemptionConversionPoints()); + } +} diff --git a/src/Redemption/AppliedPointsProviderInterface.php b/src/Redemption/AppliedPointsProviderInterface.php new file mode 100644 index 0000000..dced5b2 --- /dev/null +++ b/src/Redemption/AppliedPointsProviderInterface.php @@ -0,0 +1,17 @@ +getRedemptionConversionPoints()); + + return intdiv($points, $pointsUnit) * $program->getRedemptionConversionAmount(); + } + + public function pointsFromAmount(int $amount, LoyaltyProgramInterface $program): int + { + $amountUnit = max(1, $program->getRedemptionConversionAmount()); + + return intdiv($amount, $amountUnit) * max(1, $program->getRedemptionConversionPoints()); + } + + public function clampToCleanMultiple(int $points, LoyaltyProgramInterface $program): int + { + $pointsUnit = max(1, $program->getRedemptionConversionPoints()); + + return $points - ($points % $pointsUnit); + } +} diff --git a/src/Redemption/PointsConverterInterface.php b/src/Redemption/PointsConverterInterface.php new file mode 100644 index 0000000..d6aae26 --- /dev/null +++ b/src/Redemption/PointsConverterInterface.php @@ -0,0 +1,32 @@ +withValue($code) + ->withExpires(new \DateTimeImmutable(sprintf('+%d days', self::TTL_DAYS))) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_LAX) + ; + } + + /** + * A cheap format check before any database lookup: 8 chars of Crockford base32. + */ + public static function isValidFormat(string $code): bool + { + return 1 === preg_match('/^[0-9A-HJKMNP-TV-Z]{8}$/', $code); + } +} diff --git a/src/Referral/FraudCheck/AccountAgeCheck.php b/src/Referral/FraudCheck/AccountAgeCheck.php new file mode 100644 index 0000000..788491a --- /dev/null +++ b/src/Referral/FraudCheck/AccountAgeCheck.php @@ -0,0 +1,34 @@ +getRefereeCustomer(); + $capturedAt = $referral->getCreatedAt(); + if (!$referee instanceof CustomerInterface || null === $capturedAt || null === $referee->getCreatedAt()) { + return null; + } + + // A small tolerance: registration and referral creation happen in the same request + $registeredAt = \DateTimeImmutable::createFromInterface($referee->getCreatedAt()); + if ($registeredAt < $capturedAt->modify('-5 minutes')) { + return new FraudFlag('account_age', 'The referee account existed before the referral was captured'); + } + + return null; + } +} diff --git a/src/Referral/FraudCheck/CompositeReferralFraudCheck.php b/src/Referral/FraudCheck/CompositeReferralFraudCheck.php new file mode 100644 index 0000000..9550e64 --- /dev/null +++ b/src/Referral/FraudCheck/CompositeReferralFraudCheck.php @@ -0,0 +1,28 @@ + + */ +final class CompositeReferralFraudCheck extends CompositeService implements CompositeReferralFraudCheckInterface +{ + public function checkAll(ReferralInterface $referral, OrderInterface $order): array + { + $flags = []; + foreach ($this->services as $check) { + $flag = $check->check($referral, $order); + if (null !== $flag) { + $flags[] = $flag; + } + } + + return $flags; + } +} diff --git a/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php b/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php new file mode 100644 index 0000000..6a50cb0 --- /dev/null +++ b/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php @@ -0,0 +1,16 @@ + + */ + public function checkAll(ReferralInterface $referral, OrderInterface $order): array; +} diff --git a/src/Referral/FraudCheck/FraudFlag.php b/src/Referral/FraudCheck/FraudFlag.php new file mode 100644 index 0000000..4daa3f1 --- /dev/null +++ b/src/Referral/FraudCheck/FraudFlag.php @@ -0,0 +1,22 @@ + $this->check, 'detail' => $this->detail]; + } +} diff --git a/src/Referral/FraudCheck/ReferralFraudCheckInterface.php b/src/Referral/FraudCheck/ReferralFraudCheckInterface.php new file mode 100644 index 0000000..a408084 --- /dev/null +++ b/src/Referral/FraudCheck/ReferralFraudCheckInterface.php @@ -0,0 +1,18 @@ +getRegistrationIpHash(); + if (!$this->enabled || null === $hash) { + return null; + } + + $referrerAccount = $referral->getReferrerAccount(); + if (!$referrerAccount instanceof LoyaltyAccountInterface) { + return null; + } + + /** @var list $siblings */ + $siblings = $this->referralRepository->findBy(['referrerAccount' => $referrerAccount, 'registrationIpHash' => $hash]); + foreach ($siblings as $sibling) { + if ($sibling->getId() !== $referral->getId()) { + return new FraudFlag('registration_ip', 'Another referee of the same referrer registered from the same IP'); + } + } + + return null; + } +} diff --git a/src/Referral/FraudCheck/RewardCapCheck.php b/src/Referral/FraudCheck/RewardCapCheck.php new file mode 100644 index 0000000..eb5fb1d --- /dev/null +++ b/src/Referral/FraudCheck/RewardCapCheck.php @@ -0,0 +1,38 @@ +getReferrerAccount(); + if (!$referrerAccount instanceof LoyaltyAccountInterface) { + return null; + } + + $rewarded = $this->referralRepository->countRewardedSince($referrerAccount, new \DateTimeImmutable('-30 days')); + if ($rewarded >= $this->cap) { + return new FraudFlag('reward_cap', sprintf('The referrer already has %d rewarded referrals in 30 days (cap %d)', $rewarded, $this->cap)); + } + + return null; + } +} diff --git a/src/Referral/FraudCheck/SelfReferralCheck.php b/src/Referral/FraudCheck/SelfReferralCheck.php new file mode 100644 index 0000000..1ca9327 --- /dev/null +++ b/src/Referral/FraudCheck/SelfReferralCheck.php @@ -0,0 +1,66 @@ +getReferrerAccount()?->getCustomer(); + $referee = $referral->getRefereeCustomer(); + if (!$referrer instanceof CustomerInterface || !$referee instanceof CustomerInterface) { + return null; + } + + if ($referrer === $referee || $referrer->getId() === $referee->getId()) { + return new FraudFlag('self_referral', 'Referrer and referee are the same customer'); + } + + if (null !== $referrer->getEmail() && null !== $referee->getEmail() && + self::normalizeEmail($referrer->getEmail()) === self::normalizeEmail($referee->getEmail())) { + return new FraudFlag('self_referral', 'Referrer and referee emails normalize identically'); + } + + $referrerAddress = $referrer->getDefaultAddress(); + $refereeAddress = $referee->getDefaultAddress() ?? $order->getShippingAddress(); + if (null !== $referrerAddress && null !== $refereeAddress && + self::fuzzyAddress($referrerAddress) === self::fuzzyAddress($refereeAddress) && + '' !== self::fuzzyAddress($referrerAddress)) { + return new FraudFlag('self_referral', 'Referrer and referee share an address'); + } + + return null; + } + + private static function normalizeEmail(string $email): string + { + $email = mb_strtolower(trim($email)); + [$local, $domain] = explode('@', $email, 2) + [1 => '']; + + // Strip +alias and dots in the local part (Gmail-style aliasing) + $local = explode('+', $local, 2)[0]; + $local = str_replace('.', '', $local); + + return $local . '@' . $domain; + } + + private static function fuzzyAddress(AddressInterface $address): string + { + $street = mb_strtolower(preg_replace('/\s+/', '', (string) $address->getStreet()) ?? ''); + $postcode = mb_strtolower(preg_replace('/\s+/', '', (string) $address->getPostcode()) ?? ''); + + return '' === $street . $postcode ? '' : $street . '|' . $postcode; + } +} diff --git a/src/Referral/ReferralCodeGenerator.php b/src/Referral/ReferralCodeGenerator.php new file mode 100644 index 0000000..8198bac --- /dev/null +++ b/src/Referral/ReferralCodeGenerator.php @@ -0,0 +1,56 @@ + $accountClass + */ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly string $accountClass, + ) { + } + + public function getCode(LoyaltyAccountInterface $account): string + { + $code = $account->getReferralCode(); + if (null !== $code) { + return $code; + } + + $repository = $this->entityManager->getRepository($this->accountClass); + do { + $code = self::generate(); + } while (null !== $repository->findOneBy(['referralCode' => $code])); + + $account->setReferralCode($code); + $this->entityManager->flush(); + + return $code; + } + + private static function generate(): string + { + $code = ''; + for ($i = 0; $i < self::LENGTH; ++$i) { + $code .= self::ALPHABET[random_int(0, 31)]; + } + + return $code; + } +} diff --git a/src/Referral/ReferralCodeGeneratorInterface.php b/src/Referral/ReferralCodeGeneratorInterface.php new file mode 100644 index 0000000..d8762c6 --- /dev/null +++ b/src/Referral/ReferralCodeGeneratorInterface.php @@ -0,0 +1,15 @@ +getCustomer(); + $channel = $order->getChannel(); + if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) { + return; + } + + $referral = $this->referralRepository->findOneByRefereeAndChannel($customer, $channel); + if (null === $referral || ReferralInterface::STATUS_PENDING !== $referral->getStatus()) { + return; + } + + // Only orders placed after attribution count; a pre-registration guest order never + // qualifies (deliberately decoupled from retroactive guest points) + $placedAt = $order->getCheckoutCompletedAt(); + $capturedAt = $referral->getCreatedAt(); + if (null === $placedAt || null === $capturedAt || $placedAt < $capturedAt) { + return; + } + + // The FIRST order to reach the award moment decides — later orders never re-qualify + if (null === $referral->getRefereeFirstOrder()) { + $referral->setRefereeFirstOrder($order); + } elseif ($referral->getRefereeFirstOrder() !== $order) { + return; + } + + $program = $this->programProvider->getByChannel($channel); + + if ($order->getItemsTotal() < $program->getReferralMinOrderTotal()) { + // Below the minimum: the decision stands (stays pending until expiry) + $this->entityManager->flush(); + + return; + } + + $flags = $this->fraudCheck->checkAll($referral, $order); + if ([] !== $flags) { + $referral->setStatus(ReferralInterface::STATUS_REJECTED); + $referral->setFraudFlags(array_map( + static fn ($flag) => $flag->toArray(), + $flags, + )); + $this->entityManager->flush(); + + $this->logger->info(sprintf( + '[Loyalty] Referral %d rejected by fraud checks: %s', + (int) $referral->getId(), + implode(', ', array_map(static fn ($flag) => $flag->check, $flags)), + )); + + return; + } + + $this->requalify($referral); + } + + public function requalify(ReferralInterface $referral): void + { + $channel = $referral->getChannel(); + if (!$channel instanceof ChannelInterface) { + return; + } + + $referral->setStatus(ReferralInterface::STATUS_QUALIFIED); + $referral->setQualifiedAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + + $this->reward($referral, $this->programProvider->getByChannel($channel), $channel); + } + + private function reward(ReferralInterface $referral, LoyaltyProgramInterface $program, ChannelInterface $channel): void + { + $expiresAt = null; + if (null !== $program->getPointsExpiryDays()) { + $expiresAt = new \DateTimeImmutable(sprintf('+%d days', $program->getPointsExpiryDays())); + } + + $referrerAccount = $referral->getReferrerAccount(); + if (null !== $referrerAccount && $referrerAccount->isEnabled() && $program->getReferralReferrerPoints() > 0) { + $this->ledger->earnReferral($referrerAccount, $program->getReferralReferrerPoints(), $referral, $expiresAt); + } + + $referee = $referral->getRefereeCustomer(); + if ($referee instanceof CustomerInterface && $program->getReferralRefereePoints() > 0) { + $refereeAccount = $this->accountProvider->getByCustomerAndChannel($referee, $channel); + if ($refereeAccount->isEnabled()) { + $this->ledger->earnReferral($refereeAccount, $program->getReferralRefereePoints(), $referral, $expiresAt); + } + } + + $referral->setStatus(ReferralInterface::STATUS_REWARDED); + $this->entityManager->flush(); + } +} diff --git a/src/Referral/ReferralQualifierInterface.php b/src/Referral/ReferralQualifierInterface.php new file mode 100644 index 0000000..f6bff68 --- /dev/null +++ b/src/Referral/ReferralQualifierInterface.php @@ -0,0 +1,22 @@ + $rules */ + $rules = $this->createQueryBuilder('r') + ->andWhere('r.channel = :channel') + ->andWhere('r.trigger = :trigger') + ->andWhere('r.enabled = true') + ->setParameter('channel', $channel) + ->setParameter('trigger', $trigger) + ->orderBy('r.priority', 'DESC') + ->addOrderBy('r.id', 'ASC') + ->getQuery() + ->getResult() + ; + + return $rules; + } +} diff --git a/src/Repository/EarningRuleRepositoryInterface.php b/src/Repository/EarningRuleRepositoryInterface.php new file mode 100644 index 0000000..cb0b17f --- /dev/null +++ b/src/Repository/EarningRuleRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ +interface EarningRuleRepositoryInterface extends RepositoryInterface +{ + /** + * Returns the channel's enabled rules for the given trigger. The active window is checked + * by the evaluator (against the possibly overridden evaluation time), not here. + * + * @return list + */ + public function findForEvaluation(ChannelInterface $channel, string $trigger): array; +} diff --git a/src/Repository/LoyaltyAccountRepository.php b/src/Repository/LoyaltyAccountRepository.php new file mode 100644 index 0000000..196e578 --- /dev/null +++ b/src/Repository/LoyaltyAccountRepository.php @@ -0,0 +1,26 @@ +findOneBy([ + 'customer' => $customer, + 'channel' => $channel, + ]); + + Assert::nullOrIsInstanceOf($account, LoyaltyAccountInterface::class); + + return $account; + } +} diff --git a/src/Repository/LoyaltyAccountRepositoryInterface.php b/src/Repository/LoyaltyAccountRepositoryInterface.php new file mode 100644 index 0000000..51f4b8d --- /dev/null +++ b/src/Repository/LoyaltyAccountRepositoryInterface.php @@ -0,0 +1,18 @@ + + */ +interface LoyaltyAccountRepositoryInterface extends RepositoryInterface +{ + public function findOneByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): ?LoyaltyAccountInterface; +} diff --git a/src/Repository/LoyaltyProgramRepository.php b/src/Repository/LoyaltyProgramRepository.php new file mode 100644 index 0000000..f246c64 --- /dev/null +++ b/src/Repository/LoyaltyProgramRepository.php @@ -0,0 +1,24 @@ +findOneBy([ + 'channel' => $channel, + ]); + + Assert::nullOrIsInstanceOf($program, LoyaltyProgramInterface::class); + + return $program; + } +} diff --git a/src/Repository/LoyaltyProgramRepositoryInterface.php b/src/Repository/LoyaltyProgramRepositoryInterface.php new file mode 100644 index 0000000..c553e9b --- /dev/null +++ b/src/Repository/LoyaltyProgramRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface LoyaltyProgramRepositoryInterface extends RepositoryInterface +{ + public function findOneByChannel(ChannelInterface $channel): ?LoyaltyProgramInterface; +} diff --git a/src/Repository/LoyaltyTransactionRepository.php b/src/Repository/LoyaltyTransactionRepository.php new file mode 100644 index 0000000..e5120c3 --- /dev/null +++ b/src/Repository/LoyaltyTransactionRepository.php @@ -0,0 +1,186 @@ + $transactions */ + $transactions = $this->createQueryBuilder('t') + ->andWhere('t.account = :account') + ->setParameter('account', $account) + ->orderBy('t.occurredAt', 'ASC') + ->addOrderBy('t.id', 'ASC') + ->getQuery() + ->getResult() + ; + + return $transactions; + } + + public function findEarnOrderTransaction(OrderInterface $order): ?EarnOrderLoyaltyTransactionInterface + { + $transaction = $this->getEntityManager()->createQueryBuilder() + ->select('t') + ->from(EarnOrderLoyaltyTransaction::class, 't') + ->andWhere('t.order = :order') + ->setParameter('order', $order) + ->getQuery() + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($transaction, EarnOrderLoyaltyTransactionInterface::class); + + return $transaction; + } + + public function findRedeemTransaction(OrderInterface $order): ?RedeemLoyaltyTransactionInterface + { + $transaction = $this->getEntityManager()->createQueryBuilder() + ->select('t') + ->from(RedeemLoyaltyTransaction::class, 't') + ->andWhere('t.order = :order') + ->setParameter('order', $order) + ->getQuery() + ->getOneOrNullResult() + ; + + Assert::nullOrIsInstanceOf($transaction, RedeemLoyaltyTransactionInterface::class); + + return $transaction; + } + + public function hasRollback(RedeemLoyaltyTransactionInterface $redeem): bool + { + return null !== $this->getEntityManager()->createQueryBuilder() + ->select('r.id') + ->from(RedeemRollbackLoyaltyTransaction::class, 'r') + ->andWhere('r.redeem = :redeem') + ->setParameter('redeem', $redeem) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function findAccountIdsWithExpiredOpenLots(\DateTimeImmutable $now, int $limit, int $offset = 0): array + { + /** @var list $rows */ + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('IDENTITY(c.account) AS accountId') + ->distinct() + ->from(CreditLoyaltyTransaction::class, 'c') + ->andWhere('c.expiresAt IS NOT NULL') + ->andWhere('c.expiresAt < :now') + ->andWhere(sprintf( + 'NOT EXISTS (SELECT 1 FROM %s e WHERE e.lot = c)', + ExpireLoyaltyTransaction::class, + )) + ->setParameter('now', $now) + ->orderBy('accountId', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getScalarResult() + ; + + return array_map(static fn (array $row): int => (int) $row['accountId'], $rows); + } + + public function sumPoints(LoyaltyAccountInterface $account): int + { + return (int) $this->createQueryBuilder('t') + ->select('COALESCE(SUM(t.points), 0)') + ->andWhere('t.account = :account') + ->setParameter('account', $account) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function findHistoryPage(LoyaltyAccountInterface $account, int $page, int $limit): array + { + /** @var list $transactions */ + $transactions = $this->createQueryBuilder('t') + ->andWhere('t.account = :account') + ->andWhere('t.points != 0') + ->setParameter('account', $account) + ->orderBy('t.occurredAt', 'DESC') + ->addOrderBy('t.id', 'DESC') + ->setFirstResult(max(0, $page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + + return $transactions; + } + + public function countHistory(LoyaltyAccountInterface $account): int + { + return (int) $this->createQueryBuilder('t') + ->select('COUNT(t.id)') + ->andWhere('t.account = :account') + ->andWhere('t.points != 0') + ->setParameter('account', $account) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function sumPointsNewerThan(LoyaltyAccountInterface $account, LoyaltyTransactionInterface $transaction): int + { + return (int) $this->createQueryBuilder('t') + ->select('COALESCE(SUM(t.points), 0)') + ->andWhere('t.account = :account') + ->andWhere('t.occurredAt > :occurredAt OR (t.occurredAt = :occurredAt AND t.id > :id)') + ->setParameter('account', $account) + ->setParameter('occurredAt', $transaction->getOccurredAt()) + ->setParameter('id', $transaction->getId()) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function findEarnReferralTransactions(ReferralInterface $referral): array + { + /** @var list $transactions */ + $transactions = $this->getEntityManager() + ->getRepository(EarnReferralLoyaltyTransaction::class) + ->findBy(['referral' => $referral]) + ; + + return $transactions; + } + + public function findClawbackForEarn(CreditLoyaltyTransactionInterface $earn): ?ClawbackLoyaltyTransactionInterface + { + $clawback = $this->getEntityManager() + ->getRepository(ClawbackLoyaltyTransaction::class) + ->findOneBy(['earn' => $earn]) + ; + \assert(null === $clawback || $clawback instanceof ClawbackLoyaltyTransactionInterface); + + return $clawback; + } +} diff --git a/src/Repository/LoyaltyTransactionRepositoryInterface.php b/src/Repository/LoyaltyTransactionRepositoryInterface.php new file mode 100644 index 0000000..4bf8505 --- /dev/null +++ b/src/Repository/LoyaltyTransactionRepositoryInterface.php @@ -0,0 +1,79 @@ + + */ +interface LoyaltyTransactionRepositoryInterface extends RepositoryInterface +{ + /** + * Returns the account's full ledger in replay order (occurredAt ASC, id ASC). + * + * @return list + */ + public function findForReplay(LoyaltyAccountInterface $account): array; + + public function findEarnOrderTransaction(OrderInterface $order): ?EarnOrderLoyaltyTransactionInterface; + + public function findRedeemTransaction(OrderInterface $order): ?RedeemLoyaltyTransactionInterface; + + public function hasRollback(RedeemLoyaltyTransactionInterface $redeem): bool; + + /** + * Returns ids of accounts that have credit lots past their expiry not yet closed by an + * expire transaction. Zero-point expire entries close fully consumed lots, so this + * selection stays exact. + * + * @return list + */ + public function findAccountIdsWithExpiredOpenLots(\DateTimeImmutable $now, int $limit, int $offset = 0): array; + + /** + * The signed sum of all the account's transactions — must always equal the account's + * cached balance (ledger invariant 1). + */ + public function sumPoints(LoyaltyAccountInterface $account): int; + + /** + * One page of the customer-facing transaction history: reverse-chronological, excluding + * zero-point entries (lot closers). + * + * @return list + */ + public function findHistoryPage(LoyaltyAccountInterface $account, int $page, int $limit): array; + + public function countHistory(LoyaltyAccountInterface $account): int; + + /** + * The signed sum of every transaction newer than the given one (in replay order) — the + * bank-statement running balance of the given transaction is the cached balance minus + * this sum. + */ + public function sumPointsNewerThan(LoyaltyAccountInterface $account, LoyaltyTransactionInterface $transaction): int; + + /** + * Both parties' reward credits for a referral. + * + * @return list + */ + public function findEarnReferralTransactions(ReferralInterface $referral): array; + + /** + * The clawback that already compensates the given credit, if any. + */ + public function findClawbackForEarn(CreditLoyaltyTransactionInterface $earn): ?ClawbackLoyaltyTransactionInterface; +} diff --git a/src/Repository/ReferralRepository.php b/src/Repository/ReferralRepository.php new file mode 100644 index 0000000..24ca428 --- /dev/null +++ b/src/Repository/ReferralRepository.php @@ -0,0 +1,93 @@ +findOneBy(['refereeCustomer' => $referee, 'channel' => $channel]); + \assert(null === $referral || $referral instanceof ReferralInterface); + + return $referral; + } + + public function countRewardedSince(LoyaltyAccountInterface $referrerAccount, \DateTimeImmutable $since): int + { + return (int) $this->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->andWhere('r.referrerAccount = :account') + ->andWhere('r.status = :status') + ->andWhere('r.qualifiedAt >= :since') + ->setParameter('account', $referrerAccount) + ->setParameter('status', ReferralInterface::STATUS_REWARDED) + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function findPendingOlderThan(\DateTimeImmutable $threshold, int $limit): array + { + /** @var list $referrals */ + $referrals = $this->createQueryBuilder('r') + ->andWhere('r.status = :status') + ->andWhere('r.createdAt < :threshold') + ->setParameter('status', ReferralInterface::STATUS_PENDING) + ->setParameter('threshold', $threshold) + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + + return $referrals; + } + + public function findWithIpHashOlderThan(\DateTimeImmutable $threshold, int $limit): array + { + /** @var list $referrals */ + $referrals = $this->createQueryBuilder('r') + ->andWhere('r.registrationIpHash IS NOT NULL') + ->andWhere('r.createdAt < :threshold') + ->setParameter('threshold', $threshold) + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + + return $referrals; + } + + public function getReferrerStats(LoyaltyAccountInterface $referrerAccount): array + { + $rewarded = (int) $this->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->andWhere('r.referrerAccount = :account') + ->andWhere('r.status = :status') + ->setParameter('account', $referrerAccount) + ->setParameter('status', ReferralInterface::STATUS_REWARDED) + ->getQuery() + ->getSingleScalarResult() + ; + + $pointsEarned = (int) $this->getEntityManager()->createQueryBuilder() + ->select('COALESCE(SUM(t.points), 0)') + ->from(EarnReferralLoyaltyTransaction::class, 't') + ->andWhere('t.account = :account') + ->setParameter('account', $referrerAccount) + ->getQuery() + ->getSingleScalarResult() + ; + + return ['rewarded' => $rewarded, 'pointsEarned' => $pointsEarned]; + } +} diff --git a/src/Repository/ReferralRepositoryInterface.php b/src/Repository/ReferralRepositoryInterface.php new file mode 100644 index 0000000..5fa0eef --- /dev/null +++ b/src/Repository/ReferralRepositoryInterface.php @@ -0,0 +1,42 @@ + + */ +interface ReferralRepositoryInterface extends RepositoryInterface +{ + public function findOneByRefereeAndChannel(CustomerInterface $referee, ChannelInterface $channel): ?ReferralInterface; + + /** + * How many referrals the account was rewarded for since the given time — the reward-cap + * fraud check. + */ + public function countRewardedSince(LoyaltyAccountInterface $referrerAccount, \DateTimeImmutable $since): int; + + /** + * @return list + */ + public function findPendingOlderThan(\DateTimeImmutable $threshold, int $limit): array; + + /** + * @return list + */ + public function findWithIpHashOlderThan(\DateTimeImmutable $threshold, int $limit): array; + + /** + * Aggregate stats for the shop referral block. + * + * @return array{rewarded: int, pointsEarned: int} + */ + public function getReferrerStats(LoyaltyAccountInterface $referrerAccount): array; +} diff --git a/src/Repository/TierRepository.php b/src/Repository/TierRepository.php new file mode 100644 index 0000000..89dae07 --- /dev/null +++ b/src/Repository/TierRepository.php @@ -0,0 +1,28 @@ + $tiers */ + $tiers = $this->createQueryBuilder('t') + ->andWhere('t.channel = :channel') + ->andWhere('t.enabled = true') + ->setParameter('channel', $channel) + ->orderBy('t.position', 'DESC') + ->addOrderBy('t.id', 'ASC') + ->getQuery() + ->getResult() + ; + + return $tiers; + } +} diff --git a/src/Repository/TierRepositoryInterface.php b/src/Repository/TierRepositoryInterface.php new file mode 100644 index 0000000..3229c8f --- /dev/null +++ b/src/Repository/TierRepositoryInterface.php @@ -0,0 +1,22 @@ + + */ +interface TierRepositoryInterface extends RepositoryInterface +{ + /** + * The channel's enabled tiers, highest position first — evaluation order. + * + * @return list + */ + public function findQualifiable(ChannelInterface $channel): array; +} diff --git a/src/Resolver/TriggerChannelResolver.php b/src/Resolver/TriggerChannelResolver.php new file mode 100644 index 0000000..637ef77 --- /dev/null +++ b/src/Resolver/TriggerChannelResolver.php @@ -0,0 +1,79 @@ + $channelRepository + * @param class-string $orderClass + */ + public function __construct( + private readonly ChannelContextInterface $channelContext, + private readonly ChannelRepositoryInterface $channelRepository, + private readonly EntityManagerInterface $entityManager, + private readonly string $orderClass, + ) { + } + + public function resolve(EarningTriggerEvent $event): ?ChannelInterface + { + $channel = $event->getChannel(); + if (null !== $channel) { + return $channel; + } + + try { + return $this->channelContext->getChannel(); + } catch (ChannelNotFoundException) { + // The context could not resolve a channel for this request (e.g. CLI); fall + // through to the order-history and single-channel strategies + } + + $channel = $this->latestOrderChannel($event); + if (null !== $channel) { + return $channel; + } + + $enabledChannels = $this->channelRepository->findBy(['enabled' => true]); + if (1 === count($enabledChannels)) { + return $enabledChannels[0]; + } + + return null; + } + + private function latestOrderChannel(EarningTriggerEvent $event): ?ChannelInterface + { + $order = $this->entityManager->createQueryBuilder() + ->select('o') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.checkoutState = :checkoutState') + ->setParameter('customer', $event->getCustomer()) + ->setParameter('checkoutState', OrderCheckoutStates::STATE_COMPLETED) + ->orderBy('o.checkoutCompletedAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + + return $order instanceof OrderInterface ? $order->getChannel() : null; + } +} diff --git a/src/Resolver/TriggerChannelResolverInterface.php b/src/Resolver/TriggerChannelResolverInterface.php new file mode 100644 index 0000000..7d4e5a8 --- /dev/null +++ b/src/Resolver/TriggerChannelResolverInterface.php @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..e57f0d1 --- /dev/null +++ b/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..25115e4 --- /dev/null +++ b/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/Resources/config/doctrine/model/DryRunResult.orm.xml b/src/Resources/config/doctrine/model/DryRunResult.orm.xml new file mode 100644 index 0000000..8dc0391 --- /dev/null +++ b/src/Resources/config/doctrine/model/DryRunResult.orm.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..95a7359 --- /dev/null +++ b/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..bc91aa3 --- /dev/null +++ b/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..de1c90c --- /dev/null +++ b/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/EarningRule.orm.xml b/src/Resources/config/doctrine/model/EarningRule.orm.xml new file mode 100644 index 0000000..989deab --- /dev/null +++ b/src/Resources/config/doctrine/model/EarningRule.orm.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml b/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml new file mode 100644 index 0000000..8e43169 --- /dev/null +++ b/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..66310cb --- /dev/null +++ b/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml b/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml new file mode 100644 index 0000000..2abc953 --- /dev/null +++ b/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml b/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml new file mode 100644 index 0000000..26a6942 --- /dev/null +++ b/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml new file mode 100644 index 0000000..c40a235 --- /dev/null +++ b/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..e9aab96 --- /dev/null +++ b/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..efe52d9 --- /dev/null +++ b/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..78082ca --- /dev/null +++ b/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml new file mode 100644 index 0000000..a0b8ba9 --- /dev/null +++ b/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/Referral.orm.xml b/src/Resources/config/doctrine/model/Referral.orm.xml new file mode 100644 index 0000000..2b6935d --- /dev/null +++ b/src/Resources/config/doctrine/model/Referral.orm.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/Tier.orm.xml b/src/Resources/config/doctrine/model/Tier.orm.xml new file mode 100644 index 0000000..cceb62c --- /dev/null +++ b/src/Resources/config/doctrine/model/Tier.orm.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/doctrine/model/TierTranslation.orm.xml b/src/Resources/config/doctrine/model/TierTranslation.orm.xml new file mode 100644 index 0000000..ceaaaff --- /dev/null +++ b/src/Resources/config/doctrine/model/TierTranslation.orm.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Resources/config/routes/admin.yaml b/src/Resources/config/routes/admin.yaml index be4ae0c..73dbb2a 100644 --- a/src/Resources/config/routes/admin.yaml +++ b/src/Resources/config/routes/admin.yaml @@ -1 +1,115 @@ -# TODO Add your admin routes here \ No newline at end of file +setono_sylius_loyalty_admin_dashboard: + path: /loyalty + methods: [GET] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\DashboardAction + +setono_sylius_loyalty_admin_account_index: + path: /loyalty/accounts + methods: [GET] + defaults: + _controller: setono_sylius_loyalty.controller.account::indexAction + _sylius: + section: admin + grid: setono_sylius_loyalty_admin_account + permission: true + template: "@SyliusAdmin/Crud/index.html.twig" + +setono_sylius_loyalty_admin_account_inspect: + path: /loyalty/accounts/{id} + methods: [GET] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\InspectAccountAction + +setono_sylius_loyalty_admin_account_toggle: + path: /loyalty/accounts/{id}/toggle + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ToggleAccountAction + +setono_sylius_loyalty_admin_account_adjust: + path: /loyalty/accounts/{id}/adjust + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ManualAdjustmentAction + +setono_sylius_loyalty_admin_earning_rule: + resource: | + alias: setono_sylius_loyalty.earning_rule + section: admin + except: ['show'] + redirect: index + grid: setono_sylius_loyalty_admin_earning_rule + templates: "@SyliusAdmin\\Crud" + permission: true + path: loyalty/earning-rules + type: sylius.resource + +setono_sylius_loyalty_admin_tier: + resource: | + alias: setono_sylius_loyalty.tier + section: admin + except: ['show'] + redirect: index + grid: setono_sylius_loyalty_admin_tier + templates: "@SyliusAdmin\\Crud" + permission: true + path: loyalty/tiers + type: sylius.resource + +setono_sylius_loyalty_admin_program_index: + path: /loyalty/programs + methods: [GET] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ProgramIndexAction + +setono_sylius_loyalty_admin_program_update: + path: /loyalty/programs/{id}/edit + methods: [GET, PUT] + defaults: + _controller: setono_sylius_loyalty.controller.program::updateAction + _sylius: + section: admin + permission: true + template: "@SyliusAdmin/Crud/update.html.twig" + redirect: setono_sylius_loyalty_admin_program_index + +setono_sylius_loyalty_admin_dry_run_result_index: + path: /loyalty/dry-run-results + methods: [GET] + defaults: + _controller: setono_sylius_loyalty.controller.dry_run_result::indexAction + _sylius: + section: admin + grid: setono_sylius_loyalty_admin_dry_run_result + permission: true + template: "@SyliusAdmin/Crud/index.html.twig" + +setono_sylius_loyalty_admin_rule_tester: + path: /loyalty/rule-tester + methods: [GET, POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\RuleTesterAction + +setono_sylius_loyalty_admin_expression_lint: + path: /loyalty/expression-lint + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\LintExpressionAction + +setono_sylius_loyalty_admin_referral_index: + path: /loyalty/referrals + methods: [GET] + defaults: + _controller: setono_sylius_loyalty.controller.referral::indexAction + _sylius: + section: admin + permission: true + grid: setono_sylius_loyalty_admin_referral + template: "@SyliusAdmin/Crud/index.html.twig" + +setono_sylius_loyalty_admin_referral_override: + path: /loyalty/referrals/{id}/override + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\OverrideReferralAction diff --git a/src/Resources/config/routes/shop.yaml b/src/Resources/config/routes/shop.yaml index 2333897..7761c43 100644 --- a/src/Resources/config/routes/shop.yaml +++ b/src/Resources/config/routes/shop.yaml @@ -1 +1,23 @@ -# TODO Add your shop routes here \ No newline at end of file +setono_sylius_loyalty_shop_referral_landing: + path: /r/{code} + methods: [GET] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\ReferralLandingAction + +setono_sylius_loyalty_shop_account_loyalty: + path: /account/loyalty + methods: [GET] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\LoyaltyDashboardAction + +setono_sylius_loyalty_shop_redemption_apply: + path: /cart/loyalty-redemption + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\ApplyRedemptionAction + +setono_sylius_loyalty_shop_redemption_remove: + path: /cart/loyalty-redemption/remove + methods: [POST] + defaults: + _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\RemoveRedemptionAction diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 2a861e3..e0901a1 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -1,7 +1,26 @@ - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/admin.xml b/src/Resources/config/services/admin.xml new file mode 100644 index 0000000..1fc3dca --- /dev/null +++ b/src/Resources/config/services/admin.xml @@ -0,0 +1,74 @@ + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + %setono_sylius_loyalty.model.transaction.class% + %setono_sylius_loyalty.model.program.class% + + + + + + + + + + + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + %setono_sylius_loyalty.manual_adjustment_reasons% + + + + + + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + %setono_sylius_loyalty.manual_adjustment_reasons% + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/command.xml b/src/Resources/config/services/command.xml new file mode 100644 index 0000000..d76d90f --- /dev/null +++ b/src/Resources/config/services/command.xml @@ -0,0 +1,73 @@ + + + + + + + %sylius.model.customer.class% + + + + + + %setono_sylius_loyalty.model.dry_run_result.class% + + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + diff --git a/src/Resources/config/services/doctrine.xml b/src/Resources/config/services/doctrine.xml new file mode 100644 index 0000000..02f2815 --- /dev/null +++ b/src/Resources/config/services/doctrine.xml @@ -0,0 +1,11 @@ + + + + + %setono_sylius_loyalty.model.transaction.class% + %sylius.resources% + + + + diff --git a/src/Resources/config/services/earning_rule.xml b/src/Resources/config/services/earning_rule.xml new file mode 100644 index 0000000..ad90068 --- /dev/null +++ b/src/Resources/config/services/earning_rule.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %sylius.model.order.class% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/expression.xml b/src/Resources/config/services/expression.xml new file mode 100644 index 0000000..3e395b5 --- /dev/null +++ b/src/Resources/config/services/expression.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %sylius.model.order.class% + + + + + + + + + + + + + floor + + + + ceil + + + + round + + + + abs + + + + min + + + + max + + + + diff --git a/src/Resources/config/services/fixture.xml b/src/Resources/config/services/fixture.xml new file mode 100644 index 0000000..45644ee --- /dev/null +++ b/src/Resources/config/services/fixture.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/form.xml b/src/Resources/config/services/form.xml new file mode 100644 index 0000000..d2b612c --- /dev/null +++ b/src/Resources/config/services/form.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + %setono_sylius_loyalty.model.earning_rule_condition.class% + + + + + + + %setono_sylius_loyalty.trigger_catalog% + %setono_sylius_loyalty.model.earning_rule.class% + + + + + %setono_sylius_loyalty.model.program.class% + + + + + + %setono_sylius_loyalty.model.tier.class% + + + + + %setono_sylius_loyalty.model.tier_translation.class% + + + + + + + %setono_sylius_loyalty.expression_editor.cdn_base_url% + + + + diff --git a/src/Resources/config/services/hint.xml b/src/Resources/config/services/hint.xml new file mode 100644 index 0000000..f9838c2 --- /dev/null +++ b/src/Resources/config/services/hint.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/inspector.xml b/src/Resources/config/services/inspector.xml new file mode 100644 index 0000000..8bdadac --- /dev/null +++ b/src/Resources/config/services/inspector.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/ledger.xml b/src/Resources/config/services/ledger.xml new file mode 100644 index 0000000..909614d --- /dev/null +++ b/src/Resources/config/services/ledger.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + diff --git a/src/Resources/config/services/listener.xml b/src/Resources/config/services/listener.xml new file mode 100644 index 0000000..6aaa5bf --- /dev/null +++ b/src/Resources/config/services/listener.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + %setono_sylius_loyalty.retain_anonymized_ledger% + %setono_sylius_loyalty.model.account.class% + + + + diff --git a/src/Resources/config/services/order_earning.xml b/src/Resources/config/services/order_earning.xml new file mode 100644 index 0000000..98bf9f3 --- /dev/null +++ b/src/Resources/config/services/order_earning.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + %sylius.model.order.class% + + + + + + + + %sylius.model.customer.class% + %sylius.model.channel.class% + %sylius.model.order.class% + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/promotion.xml b/src/Resources/config/services/promotion.xml new file mode 100644 index 0000000..ff5f879 --- /dev/null +++ b/src/Resources/config/services/promotion.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/provider.xml b/src/Resources/config/services/provider.xml new file mode 100644 index 0000000..04fd457 --- /dev/null +++ b/src/Resources/config/services/provider.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/redemption.xml b/src/Resources/config/services/redemption.xml new file mode 100644 index 0000000..79f525d --- /dev/null +++ b/src/Resources/config/services/redemption.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/referral.xml b/src/Resources/config/services/referral.xml new file mode 100644 index 0000000..6617e92 --- /dev/null +++ b/src/Resources/config/services/referral.xml @@ -0,0 +1,85 @@ + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + + + + + + + + + %setono_sylius_loyalty.referral.registration_ip_check% + + + + + + + + + + %setono_sylius_loyalty.referral.reward_cap% + + + + + + + + + + + + + + + + + + + + + + + %setono_sylius_loyalty.referral.query_parameter% + + + + + + + + + + + + + %setono_sylius_loyalty.referral.registration_ip_check% + %setono_sylius_loyalty.referral.ip_hash_salt% + + + + + + + + + + + + + diff --git a/src/Resources/config/services/repository.xml b/src/Resources/config/services/repository.xml new file mode 100644 index 0000000..9fa24bd --- /dev/null +++ b/src/Resources/config/services/repository.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/shop.xml b/src/Resources/config/services/shop.xml new file mode 100644 index 0000000..0ff7920 --- /dev/null +++ b/src/Resources/config/services/shop.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + %setono_sylius_loyalty.model.account.class% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/tier.xml b/src/Resources/config/services/tier.xml new file mode 100644 index 0000000..1364ac1 --- /dev/null +++ b/src/Resources/config/services/tier.xml @@ -0,0 +1,47 @@ + + + + + + %setono_sylius_loyalty.model.transaction.class% + + + + + + %sylius.model.order.class% + + + + + + %sylius.model.order.class% + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services/trigger.xml b/src/Resources/config/services/trigger.xml new file mode 100644 index 0000000..379e1cf --- /dev/null +++ b/src/Resources/config/services/trigger.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %sylius.model.order.class% + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/validation/Order.xml b/src/Resources/config/validation/Order.xml new file mode 100644 index 0000000..575eac4 --- /dev/null +++ b/src/Resources/config/validation/Order.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/src/Resources/public/admin/expression-editor/completion.js b/src/Resources/public/admin/expression-editor/completion.js new file mode 100644 index 0000000..ddc64b0 --- /dev/null +++ b/src/Resources/public/admin/expression-editor/completion.js @@ -0,0 +1,62 @@ +/* + * Catalog-driven autocompletion: the data-catalog attribute carries the same typed + * class-to-members map that drives the server-side sandbox, so completion can never suggest + * a path the validator rejects. Dotted chains are resolved by walking the type graph. + */ + +export function createCompletionSource(catalog, trigger) { + const variables = availableVariables(catalog, trigger); + + return (context) => { + const chain = context.matchBefore(/[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]*)*/); + if (chain === null && !context.explicit) { + return null; + } + + const text = chain ? chain.text : ''; + const parts = text.split('.'); + + if (parts.length === 1) { + const options = []; + for (const [name, variable] of Object.entries(variables)) { + options.push({label: name, type: 'variable', detail: variable.type}); + } + for (const fn of catalog.functions || []) { + options.push({label: fn.name, type: 'function', detail: fn.signature, info: fn.description, apply: fn.name + '()'}); + } + + return {from: chain ? chain.from : context.pos, options, validFor: /^[a-zA-Z0-9_]*$/}; + } + + // Resolve the chain up to the last complete segment through the type graph + let type = (variables[parts[0]] || {}).type; + for (let i = 1; i < parts.length - 1 && type; i++) { + const members = catalog.types[type] || {}; + type = members[parts[i]]; + } + + const members = type ? catalog.types[type] || {} : {}; + const from = chain.from + text.lastIndexOf('.') + 1; + + return { + from, + options: Object.entries(members).map(([name, memberType]) => ({ + label: name, + type: 'property', + detail: memberType, + })), + validFor: /^[a-zA-Z0-9_]*$/, + }; + }; +} + +export function availableVariables(catalog, trigger) { + const variables = {}; + for (const [name, variable] of Object.entries(catalog.variables || {})) { + if (variable.triggers === null || !trigger || variable.triggers.includes(trigger)) { + variables[name] = variable; + } + } + + return variables; +} diff --git a/src/Resources/public/admin/expression-editor/editor.js b/src/Resources/public/admin/expression-editor/editor.js new file mode 100644 index 0000000..57ea54c --- /dev/null +++ b/src/Resources/public/admin/expression-editor/editor.js @@ -0,0 +1,92 @@ +/* + * The expression editor bootstrap. CodeMirror 6 has no official single-file browser build, so + * it is loaded as version-pinned ESM imports from a CDN (the base URL comes from the bundle + * config and can point at self-hosted files for intranet or strict-CSP setups). This glue is + * hand-written, buildless ES modules — no Node toolchain. + */ + +import {createElLanguage} from './el-language.js'; +import {createCompletionSource} from './completion.js'; +import {createLintSource} from './lint.js'; +import {renderReferencePanel} from './reference-panel.js'; + +const CODEMIRROR_VERSION = '6.0.1'; + +async function loadCodeMirror(cdnBaseUrl) { + const base = cdnBaseUrl.replace(/\/$/, ''); + + // A relative base means self-hosted files (strict-CSP/intranet setups, hermetic CI): + // plain .js shims instead of the CDN's versioned bare-specifier URLs + if (!/^https?:/.test(base)) { + const [codemirror, language, autocomplete, lint] = await Promise.all([ + import(`${base}/codemirror.js`), + import(`${base}/codemirror-language.js`), + import(`${base}/codemirror-autocomplete.js`), + import(`${base}/codemirror-lint.js`), + ]); + + return {codemirror, language, autocomplete, lint}; + } + + const [codemirror, language, autocomplete, lint] = await Promise.all([ + import(`${base}/codemirror@${CODEMIRROR_VERSION}`), + import(`${base}/@codemirror/language@6`), + import(`${base}/@codemirror/autocomplete@6`), + import(`${base}/@codemirror/lint@6`), + ]); + + return {codemirror, language, autocomplete, lint}; +} + +export async function enhance(textarea) { + if (textarea.dataset.setonoSyliusLoyaltyEnhanced === '1') { + return; + } + textarea.dataset.setonoSyliusLoyaltyEnhanced = '1'; + + const catalog = JSON.parse(textarea.dataset.catalog || '{}'); + const trigger = textarea.dataset.trigger || null; + const {codemirror, language, autocomplete, lint} = await loadCodeMirror(textarea.dataset.cdnBaseUrl || 'https://esm.sh'); + + const view = new codemirror.EditorView({ + doc: textarea.value, + extensions: [ + // The persistent way to keep a class on view.dom — CodeMirror recomputes the + // element's className on every update, wiping externally added classes + codemirror.EditorView.editorAttributes.of({class: 'setono-sylius-loyalty-expression-editor'}), + codemirror.basicSetup, + createElLanguage(language.StreamLanguage), + autocomplete.autocompletion({override: [createCompletionSource(catalog, trigger)]}), + lint.linter(createLintSource(textarea.dataset.lintUrl, trigger), {delay: 500}), + lint.lintGutter(), + codemirror.EditorView.updateListener.of((update) => { + if (update.docChanged) { + textarea.value = update.state.doc.toString(); + } + }), + ], + }); + + textarea.style.display = 'none'; + textarea.after(view.dom); + view.dom.style.cssText = 'border:1px solid rgba(34,36,38,.15);border-radius:.28em;background:#fff;min-height:3.5em;'; + + renderReferencePanel( + view.dom.parentElement, + catalog, + trigger, + (expression) => { + view.dispatch({changes: {from: 0, to: view.state.doc.length, insert: expression}}); + textarea.value = expression; + }, + JSON.parse(textarea.dataset.referenceTranslations || '{}'), + ); +} + +export function enhanceAll(root = document) { + for (const textarea of root.querySelectorAll('textarea[data-setono-sylius-loyalty-expression]')) { + enhance(textarea); + } +} + +enhanceAll(); diff --git a/src/Resources/public/admin/expression-editor/el-language.js b/src/Resources/public/admin/expression-editor/el-language.js new file mode 100644 index 0000000..7bd0de7 --- /dev/null +++ b/src/Resources/public/admin/expression-editor/el-language.js @@ -0,0 +1,54 @@ +/* + * A pragmatic StreamLanguage tokenizer for Symfony ExpressionLanguage — strings, numbers, + * operators (incl. and/or/not/in/matches), identifiers, function calls, and properties. + * Deliberately not a full Lezer grammar. + */ + +const KEYWORD_OPERATORS = ['and', 'or', 'not', 'in', 'matches', 'starts', 'ends', 'with', 'contains']; +const CONSTANTS = ['true', 'false', 'null']; + +export function createElLanguage(streamLanguage) { + return streamLanguage.define({ + name: 'expression-language', + token(stream) { + if (stream.eatSpace()) { + return null; + } + + if (stream.match(/"([^"\\]|\\.)*"/) || stream.match(/'([^'\\]|\\.)*'/)) { + return 'string'; + } + + if (stream.match(/\d+(\.\d+)?/)) { + return 'number'; + } + + if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) { + const word = stream.current(); + if (KEYWORD_OPERATORS.includes(word)) { + return 'operatorKeyword'; + } + if (CONSTANTS.includes(word)) { + return 'atom'; + } + if (stream.peek() === '(') { + return 'function'; + } + + return 'variableName'; + } + + if (stream.match(/\.[a-zA-Z_][a-zA-Z0-9_]*/)) { + return 'propertyName'; + } + + if (stream.match(/(===|!==|==|!=|<=|>=|&&|\|\||\*\*|\.\.|[+\-*/%<>!?:~])/)) { + return 'operator'; + } + + stream.next(); + + return null; + }, + }); +} diff --git a/src/Resources/public/admin/expression-editor/lint.js b/src/Resources/public/admin/expression-editor/lint.js new file mode 100644 index 0000000..a9596f3 --- /dev/null +++ b/src/Resources/public/admin/expression-editor/lint.js @@ -0,0 +1,32 @@ +/* + * Debounced inline linting through the admin-only XHR route, which runs the same server-side + * parse + whitelist used on save. + */ + +export function createLintSource(lintUrl, trigger) { + return async (view) => { + const expression = view.state.doc.toString(); + if (expression.trim() === '') { + return []; + } + + const response = await fetch(lintUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({expression, trigger: trigger || null}), + }); + + if (!response.ok) { + return []; + } + + const payload = await response.json(); + + return (payload.diagnostics || []).map((diagnostic) => ({ + from: 0, + to: view.state.doc.length, + severity: 'error', + message: diagnostic.message, + })); + }; +} diff --git a/src/Resources/public/admin/expression-editor/reference-panel.js b/src/Resources/public/admin/expression-editor/reference-panel.js new file mode 100644 index 0000000..f8164cb --- /dev/null +++ b/src/Resources/public/admin/expression-editor/reference-panel.js @@ -0,0 +1,84 @@ +/* + * The collapsible expression reference panel, generated from the same catalog that feeds + * autocompletion — custom variables and functions registered by plugin users appear + * automatically. Clicking an example inserts it into the editor. + */ + +import {availableVariables} from './completion.js'; + +export function renderReferencePanel(container, catalog, trigger, insert, translations) { + const t = (key, fallback) => (translations && translations[key]) || fallback; + + const details = document.createElement('details'); + details.className = 'setono-sylius-loyalty-expression-reference'; + details.style.marginTop = '.5em'; + + const summary = document.createElement('summary'); + summary.textContent = t('reference', 'Expression reference'); + summary.style.cursor = 'pointer'; + details.appendChild(summary); + + const body = document.createElement('div'); + body.style.cssText = 'font-size:.92em;padding:.5em .75em;border:1px solid rgba(34,36,38,.15);border-radius:.28em;margin-top:.25em;'; + + body.appendChild(section(t('variables', 'Variables'))); + const variableList = document.createElement('ul'); + for (const [name, variable] of Object.entries(availableVariables(catalog, trigger))) { + const item = document.createElement('li'); + const members = catalog.types[variable.type]; + const memberHint = members ? ' — ' + Object.keys(members).map((m) => name + '.' + m).slice(0, 4).join(', ') : ''; + item.innerHTML = `${name} (${variable.type})${memberHint}`; + variableList.appendChild(item); + } + body.appendChild(variableList); + + body.appendChild(section(t('functions', 'Functions'))); + const functionList = document.createElement('ul'); + for (const fn of catalog.functions || []) { + const item = document.createElement('li'); + item.innerHTML = `${fn.signature}`; + functionList.appendChild(item); + } + body.appendChild(functionList); + + body.appendChild(section(t('syntax', 'Syntax'))); + const syntax = document.createElement('p'); + syntax.innerHTML = 'and or not · == != < >= · a ? b : c · x in [1, 2] · name matches "/^A/" · "a" ~ "b"'; + body.appendChild(syntax); + + body.appendChild(section(t('examples', 'Examples — click to insert'))); + const examples = [ + {label: t('example_double_points', 'Double points above 500'), expression: 'basis > 50000 ? floor(basis / 50) : floor(basis / 100)'}, + {label: t('example_weekend_bonus', 'Weekend bonus'), expression: 'day_of_week() in [6, 7]'}, + {label: t('example_first_order', 'First-order bonus'), expression: 'is_first_order() ? 500 : 0'}, + ]; + const exampleList = document.createElement('ul'); + for (const example of examples) { + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = '#'; + link.textContent = example.label; + link.addEventListener('click', (event) => { + event.preventDefault(); + insert(example.expression); + }); + item.appendChild(link); + item.appendChild(document.createTextNode(' — ')); + const code = document.createElement('code'); + code.textContent = example.expression; + item.appendChild(code); + exampleList.appendChild(item); + } + body.appendChild(exampleList); + + details.appendChild(body); + container.appendChild(details); +} + +function section(title) { + const heading = document.createElement('h5'); + heading.textContent = title; + heading.style.margin = '.5em 0 .25em'; + + return heading; +} diff --git a/src/Resources/public/shop/earn-hint.js b/src/Resources/public/shop/earn-hint.js new file mode 100644 index 0000000..df1377b --- /dev/null +++ b/src/Resources/public/shop/earn-hint.js @@ -0,0 +1,50 @@ +/* + * Variant-aware earn hint: the number swaps on variant change without XHR, using the + * server-rendered per-variant map. Mirrors the selector mechanics of Sylius' + * sylius-variants-prices.js for option-based selection and listens to the variant radios + * for choice-based selection. + */ +(function () { + 'use strict'; + + var hint = document.getElementById('setono-sylius-loyalty-earn-hint'); + if (!hint) { + return; + } + + var map = document.getElementById('setono-sylius-loyalty-earn-hint-map'); + var text = document.getElementById('setono-sylius-loyalty-earn-hint-text'); + var template = hint.getAttribute('data-template'); + + function render(entry) { + var points = entry ? entry.getAttribute('data-points') : ''; + if (points === '' || points === null) { + hint.style.display = 'none'; + + return; + } + hint.style.display = ''; + text.textContent = template.replace('%points%', points); + } + + function onOptionsChange() { + var selector = ''; + document.querySelectorAll('#sylius-product-adding-to-cart select[data-option]').forEach(function (select) { + selector += '[data-' + select.getAttribute('data-option') + '="' + select.value + '"]'; + }); + if (selector !== '') { + render(map.querySelector('span' + selector)); + } + } + + function onVariantChoice(event) { + render(map.querySelector('span[data-variant-code="' + event.target.value + '"]')); + } + + document.querySelectorAll('#sylius-product-adding-to-cart select[data-option]').forEach(function (select) { + select.addEventListener('change', onOptionsChange); + }); + document.querySelectorAll('[name*="[variant]"][type="radio"]').forEach(function (radio) { + radio.addEventListener('change', onVariantChoice); + }); +})(); diff --git a/src/Resources/translations/flashes.cs.yaml b/src/Resources/translations/flashes.cs.yaml new file mode 100644 index 0000000..a65fb8f --- /dev/null +++ b/src/Resources/translations/flashes.cs.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Věrnostní účet byl deaktivován. + account_enabled: Věrnostní účet byl aktivován. + adjustment_applied: Ruční úprava byla zapsána do knihy transakcí. + adjustment_invalid: Úprava je neplatná — body musí být nenulové, poznámka je povinná a důvod musí být jedním z nakonfigurovaných kódů. + redemption_applied: Vaše body byly uplatněny na objednávku. + redemption_invalid_points: Tento počet bodů nelze na tuto objednávku uplatnit. + redemption_invalid_request: Požadavek se nepodařilo zpracovat. Zkuste to prosím znovu. + redemption_not_available: Uplatnění bodů není momentálně k dispozici. + redemption_removed: Body byly z objednávky odebrány. + referral_overridden: Doporučení bylo schváleno a odměněno. + referral_override_invalid: Požadavek se nepodařilo zpracovat. Zkuste to prosím znovu. + referral_override_not_rejected: Ručně schválit lze pouze zamítnutá doporučení. diff --git a/src/Resources/translations/flashes.da.yaml b/src/Resources/translations/flashes.da.yaml new file mode 100644 index 0000000..87065bf --- /dev/null +++ b/src/Resources/translations/flashes.da.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Loyalitetskontoen er blevet deaktiveret. + account_enabled: Loyalitetskontoen er blevet aktiveret. + adjustment_applied: Den manuelle justering er skrevet til hovedbogen. + adjustment_invalid: Justeringen er ugyldig — point skal være forskellige fra nul, notatet er obligatorisk, og årsagen skal være en af de konfigurerede koder. + redemption_applied: Dine point er blevet anvendt på ordren. + redemption_invalid_points: Det antal point kan ikke anvendes på denne ordre. + redemption_invalid_request: Anmodningen kunne ikke behandles. Prøv venligst igen. + redemption_not_available: Indløsning af point er ikke tilgængelig lige nu. + redemption_removed: Pointene er blevet fjernet fra ordren. + referral_overridden: Henvisningen er blevet godkendt og belønnet. + referral_override_invalid: Anmodningen kunne ikke behandles. Prøv venligst igen. + referral_override_not_rejected: Kun afviste henvisninger kan tilsidesættes. diff --git a/src/Resources/translations/flashes.de.yaml b/src/Resources/translations/flashes.de.yaml new file mode 100644 index 0000000..fa9117b --- /dev/null +++ b/src/Resources/translations/flashes.de.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Das Treuekonto wurde deaktiviert. + account_enabled: Das Treuekonto wurde aktiviert. + adjustment_applied: Die manuelle Anpassung wurde in den Ledger geschrieben. + adjustment_invalid: Die Anpassung ist ungültig — die Punkte müssen ungleich null sein, die Notiz ist Pflicht, und der Grund muss einer der konfigurierten Codes sein. + redemption_applied: Ihre Punkte wurden auf die Bestellung angewendet. + redemption_invalid_points: Diese Punktzahl kann auf diese Bestellung nicht angewendet werden. + redemption_invalid_request: Die Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut. + redemption_not_available: Das Einlösen von Punkten ist derzeit nicht möglich. + redemption_removed: Die Punkte wurden von der Bestellung entfernt. + referral_overridden: Die Empfehlung wurde genehmigt und belohnt. + referral_override_invalid: Die Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut. + referral_override_not_rejected: Nur abgelehnte Empfehlungen können übersteuert werden. diff --git a/src/Resources/translations/flashes.en.yaml b/src/Resources/translations/flashes.en.yaml new file mode 100644 index 0000000..a0a4e03 --- /dev/null +++ b/src/Resources/translations/flashes.en.yaml @@ -0,0 +1,13 @@ +setono_sylius_loyalty: + account_disabled: The loyalty account has been disabled. + account_enabled: The loyalty account has been enabled. + adjustment_applied: The manual adjustment has been written to the ledger. + adjustment_invalid: The adjustment is invalid — points must be non-zero, the note is mandatory, and the reason must be one of the configured codes. + redemption_applied: Your points have been applied to the order. + redemption_invalid_points: That number of points cannot be applied to this order. + redemption_invalid_request: The request could not be processed. Please try again. + redemption_not_available: Redeeming points is not available right now. + redemption_removed: The points have been removed from the order. + referral_overridden: The referral has been approved and rewarded. + referral_override_invalid: The request could not be processed. Please try again. + referral_override_not_rejected: Only rejected referrals can be overridden. diff --git a/src/Resources/translations/flashes.es.yaml b/src/Resources/translations/flashes.es.yaml new file mode 100644 index 0000000..b95633d --- /dev/null +++ b/src/Resources/translations/flashes.es.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: La cuenta de fidelidad ha sido desactivada. + account_enabled: La cuenta de fidelidad ha sido activada. + adjustment_applied: El ajuste manual se ha escrito en el libro mayor. + adjustment_invalid: El ajuste no es válido — los puntos deben ser distintos de cero, la nota es obligatoria y el motivo debe ser uno de los códigos configurados. + redemption_applied: Tus puntos se han aplicado al pedido. + redemption_invalid_points: Esa cantidad de puntos no se puede aplicar a este pedido. + redemption_invalid_request: La solicitud no se pudo procesar. Inténtalo de nuevo. + redemption_not_available: El canje de puntos no está disponible en este momento. + redemption_removed: Los puntos se han quitado del pedido. + referral_overridden: La recomendación ha sido aprobada y recompensada. + referral_override_invalid: La solicitud no se pudo procesar. Inténtalo de nuevo. + referral_override_not_rejected: Solo las recomendaciones rechazadas se pueden aprobar manualmente. diff --git a/src/Resources/translations/flashes.fi.yaml b/src/Resources/translations/flashes.fi.yaml new file mode 100644 index 0000000..02ee8f3 --- /dev/null +++ b/src/Resources/translations/flashes.fi.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Kanta-asiakastili on poistettu käytöstä. + account_enabled: Kanta-asiakastili on otettu käyttöön. + adjustment_applied: Manuaalinen oikaisu on kirjattu kirjanpitoon. + adjustment_invalid: Oikaisu on virheellinen — pisteiden on oltava nollasta poikkeavat, huomautus on pakollinen ja syyn on oltava jokin määritetyistä koodeista. + redemption_applied: Pisteesi on käytetty tilaukseen. + redemption_invalid_points: Kyseistä pistemäärää ei voi käyttää tähän tilaukseen. + redemption_invalid_request: Pyyntöä ei voitu käsitellä. Yritä uudelleen. + redemption_not_available: Pisteiden lunastus ei ole juuri nyt käytettävissä. + redemption_removed: Pisteet on poistettu tilauksesta. + referral_overridden: Suosittelu on hyväksytty ja palkittu. + referral_override_invalid: Pyyntöä ei voitu käsitellä. Yritä uudelleen. + referral_override_not_rejected: Vain hylätyt suosittelut voidaan ohittaa. diff --git a/src/Resources/translations/flashes.fr.yaml b/src/Resources/translations/flashes.fr.yaml new file mode 100644 index 0000000..d323748 --- /dev/null +++ b/src/Resources/translations/flashes.fr.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Le compte de fidélité a été désactivé. + account_enabled: Le compte de fidélité a été activé. + adjustment_applied: 'L''ajustement manuel a été inscrit dans le registre.' + adjustment_invalid: 'L''ajustement est invalide — les points doivent être différents de zéro, la note est obligatoire et la raison doit être l''un des codes configurés.' + redemption_applied: Vos points ont été appliqués à la commande. + redemption_invalid_points: Ce nombre de points ne peut pas être appliqué à cette commande. + redemption_invalid_request: 'La demande n''a pas pu être traitée. Veuillez réessayer.' + redemption_not_available: 'L''utilisation des points n''est pas disponible pour le moment.' + redemption_removed: Les points ont été retirés de la commande. + referral_overridden: Le parrainage a été approuvé et récompensé. + referral_override_invalid: 'La demande n''a pas pu être traitée. Veuillez réessayer.' + referral_override_not_rejected: 'Seuls les parrainages rejetés peuvent faire l''objet d''une dérogation.' diff --git a/src/Resources/translations/flashes.hu.yaml b/src/Resources/translations/flashes.hu.yaml new file mode 100644 index 0000000..2fed85b --- /dev/null +++ b/src/Resources/translations/flashes.hu.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: A hűségfiók letiltásra került. + account_enabled: A hűségfiók engedélyezésre került. + adjustment_applied: A kézi módosítás bekerült a főkönyvbe. + adjustment_invalid: A módosítás érvénytelen — a pontszám nem lehet nulla, a megjegyzés kötelező, és az indoknak a beállított kódok egyikének kell lennie. + redemption_applied: Pontjai felhasználásra kerültek a rendelésen. + redemption_invalid_points: Ennyi pont nem használható fel erre a rendelésre. + redemption_invalid_request: A kérést nem sikerült feldolgozni. Kérjük, próbálja újra. + redemption_not_available: A pontbeváltás jelenleg nem érhető el. + redemption_removed: A pontok eltávolításra kerültek a rendelésről. + referral_overridden: Az ajánlás jóvá lett hagyva és jutalmazva lett. + referral_override_invalid: A kérést nem sikerült feldolgozni. Kérjük, próbálja újra. + referral_override_not_rejected: Csak elutasított ajánlások bírálhatók felül. diff --git a/src/Resources/translations/flashes.it.yaml b/src/Resources/translations/flashes.it.yaml new file mode 100644 index 0000000..d066d08 --- /dev/null +++ b/src/Resources/translations/flashes.it.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: 'L''account fedeltà è stato disattivato.' + account_enabled: 'L''account fedeltà è stato attivato.' + adjustment_applied: La rettifica manuale è stata scritta nel registro. + adjustment_invalid: La rettifica non è valida — i punti devono essere diversi da zero, la nota è obbligatoria e il motivo deve essere uno dei codici configurati. + redemption_applied: 'I tuoi punti sono stati applicati all''ordine.' + redemption_invalid_points: Quel numero di punti non può essere applicato a questo ordine. + redemption_invalid_request: La richiesta non è stata elaborata. Riprova. + redemption_not_available: Il riscatto dei punti non è al momento disponibile. + redemption_removed: 'I punti sono stati rimossi dall''ordine.' + referral_overridden: Il referral è stato approvato e premiato. + referral_override_invalid: La richiesta non è stata elaborata. Riprova. + referral_override_not_rejected: Solo i referral rifiutati possono essere forzati. diff --git a/src/Resources/translations/flashes.nl.yaml b/src/Resources/translations/flashes.nl.yaml new file mode 100644 index 0000000..fb2588d --- /dev/null +++ b/src/Resources/translations/flashes.nl.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Het loyaliteitsaccount is uitgeschakeld. + account_enabled: Het loyaliteitsaccount is ingeschakeld. + adjustment_applied: De handmatige correctie is in het grootboek weggeschreven. + adjustment_invalid: De correctie is ongeldig — de punten mogen niet nul zijn, de notitie is verplicht en de reden moet een van de geconfigureerde codes zijn. + redemption_applied: Je punten zijn op de bestelling toegepast. + redemption_invalid_points: Dat aantal punten kan niet op deze bestelling worden toegepast. + redemption_invalid_request: Het verzoek kon niet worden verwerkt. Probeer het opnieuw. + redemption_not_available: Punten inwisselen is momenteel niet beschikbaar. + redemption_removed: De punten zijn van de bestelling verwijderd. + referral_overridden: De doorverwijzing is goedgekeurd en beloond. + referral_override_invalid: Het verzoek kon niet worden verwerkt. Probeer het opnieuw. + referral_override_not_rejected: Alleen afgewezen doorverwijzingen kunnen worden overruled. diff --git a/src/Resources/translations/flashes.no.yaml b/src/Resources/translations/flashes.no.yaml new file mode 100644 index 0000000..c09dea5 --- /dev/null +++ b/src/Resources/translations/flashes.no.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Lojalitetskontoen er deaktivert. + account_enabled: Lojalitetskontoen er aktivert. + adjustment_applied: Den manuelle justeringen er skrevet til hovedboken. + adjustment_invalid: Justeringen er ugyldig — point må være forskjellig fra null, notatet er obligatorisk, og årsaken må være en av de konfigurerte kodene. + redemption_applied: Pointene dine er brukt på ordren. + redemption_invalid_points: Det antallet point kan ikke brukes på denne ordren. + redemption_invalid_request: Forespørselen kunne ikke behandles. Prøv igjen. + redemption_not_available: Innløsning av point er ikke tilgjengelig akkurat nå. + redemption_removed: Pointene er fjernet fra ordren. + referral_overridden: Vervingen er godkjent og belønnet. + referral_override_invalid: Forespørselen kunne ikke behandles. Prøv igjen. + referral_override_not_rejected: Bare avviste vervinger kan overstyres. diff --git a/src/Resources/translations/flashes.pl.yaml b/src/Resources/translations/flashes.pl.yaml new file mode 100644 index 0000000..9873801 --- /dev/null +++ b/src/Resources/translations/flashes.pl.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Konto lojalnościowe zostało wyłączone. + account_enabled: Konto lojalnościowe zostało włączone. + adjustment_applied: Korekta ręczna została zapisana w księdze. + adjustment_invalid: Korekta jest nieprawidłowa — liczba punktów musi być różna od zera, notatka jest obowiązkowa, a powód musi być jednym ze skonfigurowanych kodów. + redemption_applied: Twoje punkty zostały zastosowane do zamówienia. + redemption_invalid_points: Takiej liczby punktów nie można zastosować do tego zamówienia. + redemption_invalid_request: Nie udało się przetworzyć żądania. Spróbuj ponownie. + redemption_not_available: Wymiana punktów jest obecnie niedostępna. + redemption_removed: Punkty zostały usunięte z zamówienia. + referral_overridden: Polecenie zostało zatwierdzone i nagrodzone. + referral_override_invalid: Nie udało się przetworzyć żądania. Spróbuj ponownie. + referral_override_not_rejected: Tylko odrzucone polecenia można zatwierdzić mimo to. diff --git a/src/Resources/translations/flashes.pt.yaml b/src/Resources/translations/flashes.pt.yaml new file mode 100644 index 0000000..04fac00 --- /dev/null +++ b/src/Resources/translations/flashes.pt.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: A conta de fidelidade foi desativada. + account_enabled: A conta de fidelidade foi ativada. + adjustment_applied: O ajuste manual foi registado no livro-razão. + adjustment_invalid: O ajuste é inválido — os pontos devem ser diferentes de zero, a nota é obrigatória e o motivo deve ser um dos códigos configurados. + redemption_applied: Os seus pontos foram aplicados à encomenda. + redemption_invalid_points: Esse número de pontos não pode ser aplicado a esta encomenda. + redemption_invalid_request: Não foi possível processar o pedido. Tente novamente. + redemption_not_available: O resgate de pontos não está disponível neste momento. + redemption_removed: Os pontos foram removidos da encomenda. + referral_overridden: A indicação foi aprovada e recompensada. + referral_override_invalid: Não foi possível processar o pedido. Tente novamente. + referral_override_not_rejected: Apenas as indicações rejeitadas podem ser aprovadas manualmente. diff --git a/src/Resources/translations/flashes.ro.yaml b/src/Resources/translations/flashes.ro.yaml new file mode 100644 index 0000000..6e6fdf2 --- /dev/null +++ b/src/Resources/translations/flashes.ro.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Contul de fidelitate a fost dezactivat. + account_enabled: Contul de fidelitate a fost activat. + adjustment_applied: Ajustarea manuală a fost înregistrată în registru. + adjustment_invalid: Ajustarea este invalidă — punctele trebuie să fie diferite de zero, nota este obligatorie, iar motivul trebuie să fie unul dintre codurile configurate. + redemption_applied: Punctele dumneavoastră au fost aplicate comenzii. + redemption_invalid_points: Acest număr de puncte nu poate fi aplicat acestei comenzi. + redemption_invalid_request: Cererea nu a putut fi procesată. Vă rugăm să încercați din nou. + redemption_not_available: Utilizarea punctelor nu este disponibilă momentan. + redemption_removed: Punctele au fost eliminate din comandă. + referral_overridden: Recomandarea a fost aprobată și recompensată. + referral_override_invalid: Cererea nu a putut fi procesată. Vă rugăm să încercați din nou. + referral_override_not_rejected: Doar recomandările respinse pot fi aprobate manual. diff --git a/src/Resources/translations/flashes.sv.yaml b/src/Resources/translations/flashes.sv.yaml new file mode 100644 index 0000000..658c3a4 --- /dev/null +++ b/src/Resources/translations/flashes.sv.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Lojalitetskontot har inaktiverats. + account_enabled: Lojalitetskontot har aktiverats. + adjustment_applied: Den manuella justeringen har skrivits till huvudboken. + adjustment_invalid: Justeringen är ogiltig — poängen måste vara skilda från noll, anteckningen är obligatorisk och orsaken måste vara en av de konfigurerade koderna. + redemption_applied: Dina poäng har tillämpats på ordern. + redemption_invalid_points: Det antalet poäng kan inte tillämpas på denna order. + redemption_invalid_request: Begäran kunde inte behandlas. Försök igen. + redemption_not_available: Inlösen av poäng är inte tillgänglig just nu. + redemption_removed: Poängen har tagits bort från ordern. + referral_overridden: Värvningen har godkänts och belönats. + referral_override_invalid: Begäran kunde inte behandlas. Försök igen. + referral_override_not_rejected: Endast avvisade värvningar kan åsidosättas. diff --git a/src/Resources/translations/flashes.uk.yaml b/src/Resources/translations/flashes.uk.yaml new file mode 100644 index 0000000..4ec6b3a --- /dev/null +++ b/src/Resources/translations/flashes.uk.yaml @@ -0,0 +1,14 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + account_disabled: Рахунок лояльності вимкнено. + account_enabled: Рахунок лояльності увімкнено. + adjustment_applied: Ручне коригування записано до журналу. + adjustment_invalid: Коригування некоректне — кількість балів має бути ненульовою, примітка обовʼязкова, а причина має бути одним із налаштованих кодів. + redemption_applied: Ваші бали застосовано до замовлення. + redemption_invalid_points: Таку кількість балів неможливо застосувати до цього замовлення. + redemption_invalid_request: Не вдалося обробити запит. Спробуйте ще раз. + redemption_not_available: Списання балів наразі недоступне. + redemption_removed: Бали прибрано із замовлення. + referral_overridden: Рекомендацію схвалено та винагороджено. + referral_override_invalid: Не вдалося обробити запит. Спробуйте ще раз. + referral_override_not_rejected: Вручну схвалити можна лише відхилені рекомендації. diff --git a/src/Resources/translations/messages.cs.yaml b/src/Resources/translations/messages.cs.yaml new file mode 100644 index 0000000..8d051ed --- /dev/null +++ b/src/Resources/translations/messages.cs.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Přidat podmínku + amount_configuration: Konfigurace částky + amount_configuration_help: 'JSON, např. {"points": 1, "per_amount": 100} pro body za částku, {"points": 50} pro pevný počet, {"factor": 2} pro násobitel, {"expression": "floor(basis / 100)"} pro výraz' + amount_expression: Výraz částky + amount_expression_help: Použije se, když je typ částky „Výraz“; proměnná "basis" obsahuje základ nárokovaný pravidlem v nejmenších měnových jednotkách + amount_requires_order_trigger: Typy za částku a násobitel potřebují základ objednávky a jsou dostupné pouze pro vestavěný spouštěč objednávky. + amount_type: Typ částky + condition_configuration: Konfigurace podmínky + condition_configuration_help: 'JSON, např. {"amount": 5000} nebo {"taxons": ["t_shirts"]}' + condition_expression: Výraz podmínky + condition_expression_help: Použije se, když je typ podmínky „Výraz“; podmínka projde, když je výraz pravdivý + condition_type: Typ podmínky + expression_required: Režim výrazu vyžaduje výraz. + multiplier_is_order_scoped: Pravidla s násobitelem platí pouze pro celou objednávku; pro logiku na úrovni položek použijte režim výrazu. + scope_requires_order_trigger: Rozsahy podle taxonu a produktu potřebují položky objednávky a jsou dostupné pouze pro vestavěný spouštěč objednávky. + conditions: Podmínky + conditions_match: Splnění podmínek + conditions_match_all: Všechny podmínky musí projít + conditions_match_any: Alespoň jedna podmínka musí projít + dry_run: Zkušební režim + dry_run_help: Vyhodnocovat na živém provozu a zapisovat do auditního seznamu, co by bylo přiděleno, aniž by se připsaly jakékoli body + ends_at: Končí + scope: Rozsah + scope_configuration: Konfigurace rozsahu + scope_configuration_help: 'JSON, např. {"taxons": ["t_shirts"]} nebo {"products": ["MUG"]}; pro rozsah celé objednávky ponechte prázdné' + scope_help: Pravidla s rozsahem produktu nebo taxonu si výhradně nárokují odpovídající položky; pravidla s rozsahem objednávky získávají body ze zbytku + scope_order: Celá objednávka + scope_product: Konkrétní produkty + scope_taxon: Konkrétní taxony + stackable: Kombinovatelné + stackable_help: Kombinovatelná pravidla soutěžící o stejný základ se uplatní všechna a sečtou se; nekombinovatelné pravidlo se uplatní samo (vyhrává nejvyšší priorita) + starts_at: Začíná + trigger: Spouštěč + window_timezone_help: Zadává a vyhodnocuje se v časovém pásmu nastaveném v aplikaci + earning_rule.amount: + expression: Výraz + fixed: Pevný počet bodů + multiplier: Násobitel + per_amount: Body za částku + earning_rule.condition: + cart_contains_taxon: Košík obsahuje taxon + customer_group: Skupina zákazníků + date_window: Časové okno + day_of_week: Den v týdnu + expression: Výraz + nth_order: N-tá objednávka + order_total_at_least: Celková hodnota objednávky alespoň + invalid_json: Tato hodnota není platný JSON. + program: + award_moment_order_fulfilled: Když je objednávka vyřízena + award_moment_payment_paid: Když je platba plně uhrazena + award_order_points_at: Body za objednávku přidělit při + award_order_points_at_help: Jediný okamžik životního cyklu objednávky, kdy se body přidělují — jeden na program, nikdy na pravidlo + base_currency_help: Částky jsou v nejmenších jednotkách základní měny kanálu + clawback_policy: Politika zpětného odebrání + clawback_policy_allow_negative: Povolit záporný zůstatek (vyrovná se z budoucích zisků) + clawback_policy_clamp_to_zero: Omezit zůstatek na nulu + earning_basis: Základ pro získávání + earning_basis_help: Co se počítá do získávání; samotnou sazbu získávání definují pravidla získávání + earning_basis_items_total: Součet položek (bez dopravy) + earning_basis_order_total: Celková hodnota objednávky (vč. dopravy) + include_taxes: Zahrnout daně do základu + max_redeem_percent_of_order: Max. uplatnitelné procento objednávky + max_redeem_percent_of_order_help: Podíl součtu položek objednávky, který lze pokrýt body (100 umožňuje zcela bezplatné objednávky) + min_redeem_points: Minimální počet bodů na uplatnění + points_expiry_days: Body vyprší po (dnech) + points_expiry_days_help: Ponechte prázdné, aby body nikdy nevypršely. Upozorňujeme, že vypršení uložené hodnoty podléhá v některých jurisdikcích omezením spotřebitelského práva + redemption_conversion_amount: '... mají hodnotu této částky (nejmenší jednotky)' + redemption_conversion_help: 'Převod při uplatnění: tento počet bodů...' + redemption_conversion_points: Jednotka bodů + retroactive_guest_points: Zpětné body hostů + retroactive_guest_points_help: Přidělit body za objednávky hosta před registrací, když se host zaregistruje (bez zpětného doplnění pro dřívější registrace) + rounding: Zaokrouhlování + show_earnable_in_cart: Zobrazit nápovědu k bodům v košíku + show_earnable_on_product: Zobrazit nápovědu k bodům na stránkách produktů + tier_downgrade_grace_days: Ochranná lhůta před snížením úrovně (dny) + tier_downgrade_grace_days_help: Jak dlouho si účet ponechá svou úroveň po poklesu pod prahovou hodnotu; 0 snižuje úroveň při příštím nočním vyhodnocení + tier_evaluation_window: Vyhodnocovací období úrovní + tier_window_calendar_year: Kalendářní rok + tier_window_lifetime: Za celou dobu + tier_window_rolling_12_months: Klouzavých 12 měsíců + rounding_ceil: Nahoru + rounding_floor: Dolů + rounding_round: Matematicky + promotion_rule: + customer_loyalty_tier: Věrnostní úroveň zákazníka je alespoň + minimum_tier: Minimální úroveň + tier: + benefits: Výhody + benefits_description: Popis výhod + color: Barva odznaku + earning_multiplier: Násobitel získávání + earning_multiplier_help: Použije se na každé získání bodů, dokud je účet na této úrovni (1 = beze změny) + position_help: Vyšší pozice jsou vyšší úrovně; nejvyšší pozice je nejvyšší úroveň + qualification_basis: Základ kvalifikace + threshold: Prahová hodnota + threshold_help: Jednotka závisí na základu (body, nejmenší jednotky měny nebo objednávky) + tier_basis: + amount_spent: Utracená částka + orders_count: Provedené objednávky + points_earned: Získané body + unit: + currency: nejmenší jednotky měny + orders: objednávky + points: body + trigger: + customer_birthday: Narozeniny zákazníka + customer_registered: Zákazník se zaregistroval + order_eligible: Objednávka (vestavěné) + product_review_approved: Recenze produktu schválena + ui: + account: Věrnostní účet + account_inactive: Váš věrnostní účet je momentálně neaktivní — váš zůstatek je zachován, ale získávání a uplatňování bodů je pozastaveno. + accounts: Věrnostní účty + accounts_card_description: Procházení účtů, kontrola knih transakcí, úpravy bodů + adjust: Upravit + anonymized: Anonymizováno + balance: Váš zůstatek bodů + balance_label: Zůstatek + change_on_cart: Změnit v košíku + consumptions: Spotřebováno + dashboard_subtitle: Body, pravidla získávání a nastavení programu + derived_balance: Součet knihy transakcí + dry_run: Zkušební režim + dry_run_results: Výsledky zkušebního režimu + dry_run_results_card_description: Co by pravidla ve zkušebním režimu přidělila + earn_hint_cart: 'Za tuto objednávku získáte ~%points% bodů.' + earn_hint_product: 'Nákupem tohoto produktu získáte %points% bodů.' + earning_rate_hint: 'Sazbu získávání definují pravidla získávání: základní pravidlo s rozsahem objednávky a typem za částku je výchozí sazbou obchodu.' + earning_rules: Pravidla získávání + earning_rules_card_description: Spouštěče, podmínky a částky + edit_earning_rule: Upravit pravidlo získávání + edit_program: Upravit program + new_earning_rule: Nové pravidlo získávání + description: Popis + expired: Vypršelo + expires: 'vyprší %date%' + expires_at: Vyprší + expiring_soon: '%points% bodů vyprší před %date%.' + history: Historie transakcí + history_description: + clawback: 'Body odebrané za zrušenou nebo refundovanou objednávku' + earn_action: 'Získané body' + earn_order: 'Získáno za objednávku %order%' + earn_referral: 'Odměna za doporučení' + expire: 'Body vypršely' + manual_credit: 'Úprava: %reason%' + manual_debit: 'Úprava: %reason%' + redeem: 'Uplatněno na objednávku %order%' + redeem_rollback: 'Body vrácené ze zrušené objednávky' + history_empty: Zatím žádné transakce — body, které získáte a utratíte, se zobrazí zde. + my_loyalty: Můj věrnostní program + running_balance: Zůstatek + inspect: Prozkoumat + invariants_hold: Všechny invarianty knihy transakcí platí. + invariants_violated: Invarianty knihy transakcí jsou porušeny — před ručními opravami proveďte šetření. + ledger: Kniha transakcí + lifetime_earned: Celkem získáno + lot: Dávka + lots: Dávky (odvozené přehráním) + loyalty: Věrnostní program + manual_adjustment: Ruční úprava + manual_reason: + correction: Oprava + goodwill: Vstřícnost + other: Jiné + promotion: Promoakce + never: nikdy + note: Poznámka + points: Body + points_amount: '%points% bodů' + points_applied: 'Uplatněno %points% bodů' + points_redemption: Uplatnění bodů + program: Program + program_card_description: Převod, vypršení a zásady pro jednotlivé kanály + reason: Důvod + refer_a_friend: Doporučte přítele + referee: Doporučený + referral_explainer: Sdílejte svůj odkaz — když přítel provede svou první objednávku, body získáte oba. + referral_override: Přesto schválit + referral_stats: 'Máte %count% odměněných doporučení, která vynesla %points% bodů.' + referral_status: + expired: Vypršelo + pending: Čeká + qualified: Kvalifikované + rejected: Zamítnuté + rewarded: Odměněné + referrals: Doporučení + referrals_card_description: Stavy doporučení a ruční schválení při podezření na podvod + referrer: Doporučitel + redemption_clamped: 'Váš požadavek na %requested% bodů je na tuto objednávku uplatněn jen částečně; automaticky se opět navýší, jakmile to objednávka umožní.' + remaining: Zbývá + remove_redemption: Odebrat + rule: Pravidlo + rule_tester: Tester pravidel + rule_tester_card_description: Vyhodnotit pravidla na nedávné objednávce, volitelně k jinému datu + scope: Rozsah + share: Sdílet + copied: Zkopírováno! + stat_accounts: Účty + stat_active_accounts_90_days: Aktivní účty (90 dní) + stat_liability: Nesplacený závazek z bodů + stat_redemption_rate_90_days: Míra uplatnění (90 dní) + stat_earned_30_days: Získané body (30 dní) + stat_redeemed_30_days: Uplatněné body (30 dní) + tester_applied: Uplatněno + tester_claimed_basis: Nárokovaný základ + tester_claimed_by: Nárokováno + tester_evaluate: Vyhodnotit + tester_evaluate_at: Vyhodnotit, jako by bylo + tester_evaluate_at_help: Náhled naplánovaných pravidel před otevřením jejich okna + tester_failed_conditions: Nesplněné podmínky + tester_final_award: 'Konečné přidělení: %points% bodů' + tester_invalid_date: Datum vyhodnocení je neplatné. + tester_item_claims: Nároky podle položek + tester_matched: Odpovídá + tester_order_has_no_customer: Objednávka nemá zákazníka, takže nelze vyhodnotit žádná pravidla. + tester_order_not_found: Objednávka s tímto číslem nebyla nalezena. + tester_order_number: Číslo objednávky + tester_rules: Vyhodnocení pravidel + tier: Úroveň + tier_progress: '%metric% / %threshold% do %tier%' + tier_top: Dosáhli jste nejvyšší úrovně — děkujeme, že patříte mezi naše úplně nejlepší zákazníky! + tiers: Úrovně + tiers_card_description: Úrovně, prahové hodnoty, násobitele a výhody + trigger: Spouštěč + use_max: Použít maximum + your_code: Váš kód + use_your_points: Využijte své body diff --git a/src/Resources/translations/messages.da.yaml b/src/Resources/translations/messages.da.yaml new file mode 100644 index 0000000..6e8569e --- /dev/null +++ b/src/Resources/translations/messages.da.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Tilføj betingelse + amount_configuration: Beløbskonfiguration + amount_configuration_help: 'JSON, f.eks. {"points": 1, "per_amount": 100} for pr. beløb, {"points": 50} for fast, {"factor": 2} for multiplikator, {"expression": "floor(basis / 100)"} for udtryk' + amount_expression: Beløbsudtryk + amount_expression_help: Bruges, når beløbstypen er "Udtryk"; variablen "basis" indeholder den basis, reglen gør krav på, i mindste enheder + amount_requires_order_trigger: Typerne pr. beløb og multiplikator kræver en ordrebasis og er kun tilgængelige for den indbyggede ordre-trigger. + amount_type: Beløbstype + condition_configuration: Betingelseskonfiguration + condition_configuration_help: 'JSON, f.eks. {"amount": 5000} eller {"taxons": ["t_shirts"]}' + condition_expression: Betingelsesudtryk + condition_expression_help: Bruges, når betingelsestypen er "Udtryk"; betingelsen er opfyldt, når udtrykket evalueres som sandt + condition_type: Betingelsestype + expression_required: Udtrykstilstand kræver et udtryk. + multiplier_is_order_scoped: Multiplikatorregler gælder kun for hele ordren; brug udtrykstilstand til logik på varelinjeniveau. + scope_requires_order_trigger: Taxon- og produktafgrænsninger kræver ordrelinjer og er kun tilgængelige for den indbyggede ordre-trigger. + conditions: Betingelser + conditions_match: Match af betingelser + conditions_match_all: Alle betingelser skal være opfyldt + conditions_match_any: Mindst én betingelse skal være opfyldt + dry_run: Testkørsel + dry_run_help: Evaluer mod den faktiske trafik og log til revisionslisten, hvad der ville være blevet tildelt, uden at skrive nogen point + ends_at: Slutter + scope: Afgrænsning + scope_configuration: Afgrænsningskonfiguration + scope_configuration_help: 'JSON, f.eks. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; lad feltet stå tomt for hele ordren' + scope_help: Produkt- eller taxonafgrænsede regler gør eksklusivt krav på deres matchende varer; ordreafgrænsede regler optjener på resten + scope_order: Hele ordren + scope_product: Bestemte produkter + scope_taxon: Bestemte taxoner + stackable: Kan kombineres + stackable_help: Regler, der kan kombineres og konkurrerer om samme basis, gælder alle og lægges sammen; en regel, der ikke kan kombineres, gælder alene (højeste prioritet vinder) + starts_at: Starter + trigger: Trigger + window_timezone_help: Indtastes og evalueres i applikationens konfigurerede tidszone + earning_rule.amount: + expression: Udtryk + fixed: Faste point + multiplier: Multiplikator + per_amount: Point pr. beløb + earning_rule.condition: + cart_contains_taxon: Kurven indeholder taxon + customer_group: Kundegruppe + date_window: Datointerval + day_of_week: Ugedag + expression: Udtryk + nth_order: N'te ordre + order_total_at_least: Ordretotal mindst + invalid_json: Denne værdi er ikke gyldig JSON. + program: + award_moment_order_fulfilled: Når ordren er gennemført + award_moment_payment_paid: Når betalingen er fuldt betalt + award_order_points_at: Tidspunkt for tildeling af ordrepoint + award_order_points_at_help: Det ene tidspunkt i ordreforløbet, hvor point tildeles — ét pr. program, aldrig pr. regel + base_currency_help: Beløb angives i mindste enheder af kanalens basisvaluta + clawback_policy: Tilbageførselspolitik + clawback_policy_allow_negative: Tillad negativ saldo (inddrives fra fremtidig optjening) + clawback_policy_clamp_to_zero: Begræns saldoen til nul + earning_basis: Optjeningsgrundlag + earning_basis_help: Hvad der tæller med i optjeningen; selve optjeningssatsen defineres af optjeningsregler + earning_basis_items_total: Varetotal (ekskl. fragt) + earning_basis_order_total: Ordretotal (inkl. fragt) + include_taxes: Medregn moms i grundlaget + max_redeem_percent_of_order: Maks. indløselig procent af ordren + max_redeem_percent_of_order_help: Den andel af ordrens varetotal, der kan dækkes af point (100 tillader helt gratis ordrer) + min_redeem_points: Minimum point pr. indløsning + points_expiry_days: Point udløber efter (dage) + points_expiry_days_help: Lad feltet stå tomt, så point aldrig udløber. Bemærk, at udløb af opsparet værdi kan være underlagt forbrugerretlige begrænsninger i nogle jurisdiktioner + redemption_conversion_amount: '... er dette beløb værd (mindste enheder)' + redemption_conversion_help: 'Indløsningskursen: så mange point...' + redemption_conversion_points: Pointenhed + retroactive_guest_points: Point for tidligere gæsteordrer + retroactive_guest_points_help: Tildel point for gæsteordrer afgivet før registrering, når gæsten registrerer sig (ingen efterbetaling for tidligere registreringer) + rounding: Afrunding + show_earnable_in_cart: Vis optjeningshint i kurven + show_earnable_on_product: Vis optjeningshint på produktsider + tier_downgrade_grace_days: Henstand ved niveaunedrykning (dage) + tier_downgrade_grace_days_help: Hvor længe en konto beholder sit niveau efter at være faldet under tærsklen; 0 nedrykker ved næste natlige evaluering + tier_evaluation_window: Evalueringsvindue for niveauer + tier_window_calendar_year: Kalenderår + tier_window_lifetime: Levetid + tier_window_rolling_12_months: Rullende 12 måneder + rounding_ceil: Rund op + rounding_floor: Rund ned + rounding_round: Afrund + promotion_rule: + customer_loyalty_tier: Kundens loyalitetsniveau er mindst + minimum_tier: Minimumsniveau + tier: + benefits: Fordele + benefits_description: Beskrivelse af fordele + color: Badgefarve + earning_multiplier: Optjeningsmultiplikator + earning_multiplier_help: Anvendes på al pointoptjening, mens kontoen er på dette niveau (1 = ingen ændring) + position_help: Højere positioner er højere niveauer; den højeste position er topniveauet + qualification_basis: Kvalifikationsgrundlag + threshold: Tærskel + threshold_help: Enheden afhænger af grundlaget (point, mindste valutaenheder eller ordrer) + tier_basis: + amount_spent: Forbrugt beløb + orders_count: Afgivne ordrer + points_earned: Optjente point + unit: + currency: mindste valutaenheder + orders: ordrer + points: point + trigger: + customer_birthday: Kundens fødselsdag + customer_registered: Kunde registreret + order_eligible: Ordre (indbygget) + product_review_approved: Produktanmeldelse godkendt + ui: + account: Loyalitetskonto + account_inactive: Din loyalitetskonto er i øjeblikket inaktiv — din saldo er bevaret, men optjening og indløsning er sat på pause. + accounts: Loyalitetskonti + accounts_card_description: Gennemse konti, inspicér hovedbøger, justér point + adjust: Justér + anonymized: Anonymiseret + balance: Din pointsaldo + balance_label: Saldo + change_on_cart: Ændr i kurven + consumptions: Forbrugt af + dashboard_subtitle: Point, optjeningsregler og programindstillinger + derived_balance: Sum i hovedbogen + dry_run: Testkørsel + dry_run_results: Testkørselsresultater + dry_run_results_card_description: Hvad testkørselsregler ville have tildelt + earn_hint_cart: 'Denne ordre optjener ~%points% point.' + earn_hint_product: 'Du optjener %points% point ved køb af dette produkt.' + earning_rate_hint: 'Optjeningssatsen defineres af optjeningsregler: en basisregel for hele ordren med typen pr. beløb er butikkens standardsats.' + earning_rules: Optjeningsregler + earning_rules_card_description: Triggere, betingelser og beløb + edit_earning_rule: Redigér optjeningsregel + edit_program: Redigér program + new_earning_rule: Ny optjeningsregel + description: Beskrivelse + expired: Udløbet + expires: 'udløber %date%' + expires_at: Udløber + expiring_soon: '%points% point udløber inden %date%.' + history: Transaktionshistorik + history_description: + clawback: 'Point tilbageført for en annulleret eller refunderet ordre' + earn_action: 'Point optjent' + earn_order: 'Optjent på ordre %order%' + earn_referral: 'Henvisningsbelønning' + expire: 'Point udløbet' + manual_credit: 'Justering: %reason%' + manual_debit: 'Justering: %reason%' + redeem: 'Indløst på ordre %order%' + redeem_rollback: 'Point gendannet fra en annulleret ordre' + history_empty: Ingen transaktioner endnu — point, du optjener og bruger, vises her. + my_loyalty: Mit loyalitetsprogram + running_balance: Saldo + inspect: Inspicér + invariants_hold: Alle hovedbogens invarianter holder. + invariants_violated: Hovedbogens invarianter er brudt — undersøg sagen, før der foretages manuelle rettelser. + ledger: Hovedbog + lifetime_earned: Optjent i alt + lot: Lot + lots: Lots (afledt ved genafspilning) + loyalty: Loyalitet + manual_adjustment: Manuel justering + manual_reason: + correction: Rettelse + goodwill: Goodwill + other: Andet + promotion: Kampagne + never: aldrig + note: Notat + points: Point + points_amount: '%points% point' + points_applied: '%points% point anvendt' + points_redemption: Pointindløsning + program: Program + program_card_description: Konvertering, udløb og politikker pr. kanal + reason: Årsag + refer_a_friend: Henvis en ven + referee: Henvist + referral_explainer: Del dit link — når en ven afgiver sin første ordre, optjener I begge point. + referral_override: Godkend alligevel + referral_stats: 'Du har %count% belønnede henvisning(er), som har givet %points% point.' + referral_status: + expired: Udløbet + pending: Afventer + qualified: Kvalificeret + rejected: Afvist + rewarded: Belønnet + referrals: Henvisninger + referrals_card_description: Henvisningsstatusser og tilsidesættelse af svindeltjek + referrer: Henviser + redemption_clamped: 'Din anmodning om %requested% point er kun delvist anvendt på denne ordre; den øges automatisk igen, når ordren tillader det.' + remaining: Resterende + remove_redemption: Fjern + rule: Regel + rule_tester: Regeltester + rule_tester_card_description: Evaluer regler mod en nylig ordre, eventuelt på en anden dato + scope: Afgrænsning + share: Del + copied: Kopieret! + stat_accounts: Konti + stat_active_accounts_90_days: Aktive konti (90 dage) + stat_liability: Udestående pointforpligtelse + stat_redemption_rate_90_days: Indløsningsrate (90 dage) + stat_earned_30_days: Point optjent (30 dage) + stat_redeemed_30_days: Point indløst (30 dage) + tester_applied: Anvendt + tester_claimed_basis: Krævet basis + tester_claimed_by: Krævet af + tester_evaluate: Evaluer + tester_evaluate_at: Evaluer som om det var + tester_evaluate_at_help: Se planlagte regler, før deres tidsvindue åbner + tester_failed_conditions: Ikke-opfyldte betingelser + tester_final_award: 'Endelig tildeling: %points% point' + tester_invalid_date: Evalueringsdatoen er ugyldig. + tester_item_claims: Krav pr. varelinje + tester_matched: Matchet + tester_order_has_no_customer: Ordren har ingen kunde, så ingen regler kan evalueres. + tester_order_not_found: Der blev ikke fundet nogen ordre med det nummer. + tester_order_number: Ordrenummer + tester_rules: Regelevalueringer + tier: Niveau + tier_progress: '%metric% / %threshold% til %tier%' + tier_top: Du har nået det højeste niveau — tak, fordi du er en af vores allerbedste kunder! + tiers: Niveauer + tiers_card_description: Niveauer, tærskler, multiplikatorer og fordele + trigger: Trigger + use_max: Brug maks. + your_code: Din kode + use_your_points: Brug dine point diff --git a/src/Resources/translations/messages.de.yaml b/src/Resources/translations/messages.de.yaml new file mode 100644 index 0000000..ed80fe6 --- /dev/null +++ b/src/Resources/translations/messages.de.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Bedingung hinzufügen + amount_configuration: Betragskonfiguration + amount_configuration_help: 'JSON, z. B. {"points": 1, "per_amount": 100} für "pro Betrag", {"points": 50} für fest, {"factor": 2} für Multiplikator, {"expression": "floor(basis / 100)"} für Ausdruck' + amount_expression: Betragsausdruck + amount_expression_help: Wird verwendet, wenn der Betragstyp "Ausdruck" ist; die Variable "basis" enthält die von der Regel beanspruchte Basis in kleinsten Währungseinheiten + amount_requires_order_trigger: Die Typen "pro Betrag" und "Multiplikator" benötigen eine Bestellbasis und sind nur für den eingebauten Bestell-Trigger verfügbar. + amount_type: Betragstyp + condition_configuration: Bedingungskonfiguration + condition_configuration_help: 'JSON, z. B. {"amount": 5000} oder {"taxons": ["t_shirts"]}' + condition_expression: Bedingungsausdruck + condition_expression_help: Wird verwendet, wenn der Bedingungstyp "Ausdruck" ist; die Bedingung ist erfüllt, wenn der Ausdruck wahr ist + condition_type: Bedingungstyp + expression_required: Der Ausdrucksmodus benötigt einen Ausdruck. + multiplier_is_order_scoped: Multiplikatorregeln gelten nur für die gesamte Bestellung; verwenden Sie den Ausdrucksmodus für Logik auf Positionsebene. + scope_requires_order_trigger: Taxon- und Produktgeltungsbereiche benötigen Bestellpositionen und sind nur für den eingebauten Bestell-Trigger verfügbar. + conditions: Bedingungen + conditions_match: Bedingungsverknüpfung + conditions_match_all: Alle Bedingungen müssen erfüllt sein + conditions_match_any: Mindestens eine Bedingung muss erfüllt sein + dry_run: Testlauf + dry_run_help: Wertet gegen den Live-Traffic aus und protokolliert in der Audit-Liste, was vergeben worden wäre, ohne Punkte zu schreiben + ends_at: Endet am + scope: Geltungsbereich + scope_configuration: Geltungsbereichskonfiguration + scope_configuration_help: 'JSON, z. B. {"taxons": ["t_shirts"]} oder {"products": ["MUG"]}; leer lassen für den Bestellgeltungsbereich' + scope_help: Produkt- oder taxonbezogene Regeln beanspruchen ihre passenden Positionen exklusiv; bestellbezogene Regeln sammeln auf dem Rest + scope_order: Gesamte Bestellung + scope_product: Bestimmte Produkte + scope_taxon: Bestimmte Taxone + stackable: Kombinierbar + stackable_help: Kombinierbare Regeln, die um dieselbe Basis konkurrieren, gelten alle und werden summiert; eine nicht kombinierbare Regel gilt allein (höchste Priorität gewinnt) + starts_at: Beginnt am + trigger: Trigger + window_timezone_help: Wird in der konfigurierten Zeitzone der Anwendung eingegeben und ausgewertet + earning_rule.amount: + expression: Ausdruck + fixed: Feste Punkte + multiplier: Multiplikator + per_amount: Punkte pro Betrag + earning_rule.condition: + cart_contains_taxon: Warenkorb enthält Taxon + customer_group: Kundengruppe + date_window: Datumsfenster + day_of_week: Wochentag + expression: Ausdruck + nth_order: N-te Bestellung + order_total_at_least: Bestellsumme mindestens + invalid_json: Dieser Wert ist kein gültiges JSON. + program: + award_moment_order_fulfilled: Wenn die Bestellung abgeschlossen ist + award_moment_payment_paid: Wenn die Zahlung vollständig bezahlt ist + award_order_points_at: Zeitpunkt der Bestellpunktevergabe + award_order_points_at_help: Der eine Moment im Bestelllebenszyklus, in dem Punkte vergeben werden — einer pro Programm, nie pro Regel + base_currency_help: Beträge sind in kleinsten Währungseinheiten der Basiswährung des Kanals angegeben + clawback_policy: Rückbuchungsrichtlinie + clawback_policy_allow_negative: Negativen Saldo zulassen (wird mit künftigen Gutschriften verrechnet) + clawback_policy_clamp_to_zero: Saldo auf null begrenzen + earning_basis: Sammelbasis + earning_basis_help: Was für das Sammeln zählt; die Sammelrate selbst wird durch Sammelregeln definiert + earning_basis_items_total: Artikelsumme (ohne Versand) + earning_basis_order_total: Bestellsumme (inkl. Versand) + include_taxes: Steuern in die Basis einbeziehen + max_redeem_percent_of_order: Max. einlösbarer Prozentsatz der Bestellung + max_redeem_percent_of_order_help: Der Anteil der Artikelsumme der Bestellung, der mit Punkten gedeckt werden kann (100 erlaubt komplett kostenlose Bestellungen) + min_redeem_points: Mindestpunkte pro Einlösung + points_expiry_days: Punkte verfallen nach (Tagen) + points_expiry_days_help: Leer lassen, damit Punkte nie verfallen. Beachten Sie, dass der Verfall gespeicherter Werte in manchen Rechtsordnungen verbraucherrechtlichen Beschränkungen unterliegt + redemption_conversion_amount: '... sind diesen Betrag wert (kleinste Währungseinheiten)' + redemption_conversion_help: 'Der Einlösungskurs: so viele Punkte...' + redemption_conversion_points: Punkteeinheit + retroactive_guest_points: Rückwirkende Gastpunkte + retroactive_guest_points_help: Punkte für Gastbestellungen vor der Registrierung vergeben, wenn sich der Gast registriert (keine Nachbuchung für frühere Registrierungen) + rounding: Rundung + show_earnable_in_cart: Punktehinweis im Warenkorb anzeigen + show_earnable_on_product: Punktehinweis auf Produktseiten anzeigen + tier_downgrade_grace_days: Karenzzeit bei Stufenabstieg (Tage) + tier_downgrade_grace_days_help: Wie lange ein Konto seine Stufe behält, nachdem es unter den Schwellenwert gefallen ist; 0 stuft bei der nächsten nächtlichen Auswertung herab + tier_evaluation_window: Auswertungszeitraum für Stufen + tier_window_calendar_year: Kalenderjahr + tier_window_lifetime: Gesamte Laufzeit + tier_window_rolling_12_months: Rollierende 12 Monate + rounding_ceil: Aufrunden + rounding_floor: Abrunden + rounding_round: Kaufmännisch runden + promotion_rule: + customer_loyalty_tier: Treuestufe des Kunden ist mindestens + minimum_tier: Mindeststufe + tier: + benefits: Vorteile + benefits_description: Beschreibung der Vorteile + color: Badge-Farbe + earning_multiplier: Sammelmultiplikator + earning_multiplier_help: Wird auf jeden Punktegewinn angewendet, solange das Konto in dieser Stufe ist (1 = keine Änderung) + position_help: Höhere Positionen sind höhere Stufen; die höchste Position ist die Spitzenstufe + qualification_basis: Qualifikationsgrundlage + threshold: Schwellenwert + threshold_help: Die Einheit hängt von der Grundlage ab (Punkte, kleinste Währungseinheiten oder Bestellungen) + tier_basis: + amount_spent: Ausgegebener Betrag + orders_count: Aufgegebene Bestellungen + points_earned: Gesammelte Punkte + unit: + currency: kleinste Währungseinheiten + orders: Bestellungen + points: Punkte + trigger: + customer_birthday: Geburtstag des Kunden + customer_registered: Kunde registriert + order_eligible: Bestellung (eingebaut) + product_review_approved: Produktbewertung freigegeben + ui: + account: Treuekonto + account_inactive: Ihr Treuekonto ist derzeit inaktiv — Ihr Saldo bleibt erhalten, aber Sammeln und Einlösen sind pausiert. + accounts: Treuekonten + accounts_card_description: Konten durchsuchen, Ledger einsehen, Punkte anpassen + adjust: Anpassen + anonymized: Anonymisiert + balance: Ihr Punktestand + balance_label: Saldo + change_on_cart: Im Warenkorb ändern + consumptions: Verbraucht durch + dashboard_subtitle: Punkte, Sammelregeln und Programmeinstellungen + derived_balance: Ledger-Summe + dry_run: Testlauf + dry_run_results: Testlauf-Ergebnisse + dry_run_results_card_description: Was Testlauf-Regeln vergeben hätten + earn_hint_cart: 'Diese Bestellung bringt ~%points% Punkte.' + earn_hint_product: 'Beim Kauf dieses Produkts sammeln Sie %points% Punkte.' + earning_rate_hint: 'Die Sammelrate wird durch Sammelregeln definiert: Eine Basisregel für die gesamte Bestellung mit dem Typ "pro Betrag" ist die Standardrate des Shops.' + earning_rules: Sammelregeln + earning_rules_card_description: Trigger, Bedingungen und Beträge + edit_earning_rule: Sammelregel bearbeiten + edit_program: Programm bearbeiten + new_earning_rule: Neue Sammelregel + description: Beschreibung + expired: Verfallen + expires: 'verfällt am %date%' + expires_at: Verfällt am + expiring_soon: '%points% Punkte verfallen vor dem %date%.' + history: Buchungshistorie + history_description: + clawback: 'Punkte zurückgefordert für eine stornierte oder erstattete Bestellung' + earn_action: 'Punkte gesammelt' + earn_order: 'Gesammelt mit Bestellung %order%' + earn_referral: 'Empfehlungsprämie' + expire: 'Punkte verfallen' + manual_credit: 'Anpassung: %reason%' + manual_debit: 'Anpassung: %reason%' + redeem: 'Eingelöst bei Bestellung %order%' + redeem_rollback: 'Punkte aus einer stornierten Bestellung wiederhergestellt' + history_empty: Noch keine Buchungen — Punkte, die Sie sammeln und einlösen, erscheinen hier. + my_loyalty: Mein Treueprogramm + running_balance: Saldo + inspect: Einsehen + invariants_hold: Alle Ledger-Invarianten sind erfüllt. + invariants_violated: Ledger-Invarianten sind verletzt — untersuchen Sie dies, bevor Sie manuelle Korrekturen vornehmen. + ledger: Ledger + lifetime_earned: Insgesamt gesammelt + lot: Lot + lots: Lots (per Replay abgeleitet) + loyalty: Treueprogramm + manual_adjustment: Manuelle Anpassung + manual_reason: + correction: Korrektur + goodwill: Kulanz + other: Sonstiges + promotion: Aktion + never: nie + note: Notiz + points: Punkte + points_amount: '%points% Punkte' + points_applied: '%points% Punkte angewendet' + points_redemption: Punkteeinlösung + program: Programm + program_card_description: Umrechnung, Verfall und Richtlinien pro Kanal + reason: Grund + refer_a_friend: Freunde werben + referee: Geworbener + referral_explainer: Teilen Sie Ihren Link — wenn ein Freund seine erste Bestellung aufgibt, sammeln Sie beide Punkte. + referral_override: Trotzdem genehmigen + referral_stats: 'Sie haben %count% belohnte Empfehlung(en), die %points% Punkte eingebracht haben.' + referral_status: + expired: Abgelaufen + pending: Ausstehend + qualified: Qualifiziert + rejected: Abgelehnt + rewarded: Belohnt + referrals: Empfehlungen + referrals_card_description: Empfehlungsstatus und Übersteuerung von Betrugsprüfungen + referrer: Werber + redemption_clamped: 'Ihre Anforderung von %requested% Punkten wird auf diese Bestellung nur teilweise angewendet; sie wächst automatisch wieder an, sobald die Bestellung es zulässt.' + remaining: Verbleibend + remove_redemption: Entfernen + rule: Regel + rule_tester: Regel-Tester + rule_tester_card_description: Regeln gegen eine aktuelle Bestellung auswerten, optional zu einem anderen Datum + scope: Geltungsbereich + share: Teilen + copied: Kopiert! + stat_accounts: Konten + stat_active_accounts_90_days: Aktive Konten (90 Tage) + stat_liability: Ausstehende Punkteverbindlichkeit + stat_redemption_rate_90_days: Einlösequote (90 Tage) + stat_earned_30_days: Gesammelte Punkte (30 Tage) + stat_redeemed_30_days: Eingelöste Punkte (30 Tage) + tester_applied: Angewendet + tester_claimed_basis: Beanspruchte Basis + tester_claimed_by: Beansprucht durch + tester_evaluate: Auswerten + tester_evaluate_at: Auswerten, als wäre es + tester_evaluate_at_help: Geplante Regeln vor Öffnung ihres Zeitfensters in der Vorschau prüfen + tester_failed_conditions: Nicht erfüllte Bedingungen + tester_final_award: 'Endgültige Vergabe: %points% Punkte' + tester_invalid_date: Das Auswertungsdatum ist ungültig. + tester_item_claims: Ansprüche pro Position + tester_matched: Zutreffend + tester_order_has_no_customer: Die Bestellung hat keinen Kunden, daher können keine Regeln ausgewertet werden. + tester_order_not_found: Es wurde keine Bestellung mit dieser Nummer gefunden. + tester_order_number: Bestellnummer + tester_rules: Regelauswertungen + tier: Stufe + tier_progress: '%metric% / %threshold% bis %tier%' + tier_top: Sie haben die höchste Stufe erreicht — vielen Dank, dass Sie zu unseren allerbesten Kunden gehören! + tiers: Stufen + tiers_card_description: Stufen, Schwellenwerte, Multiplikatoren und Vorteile + trigger: Trigger + use_max: Maximum verwenden + your_code: Ihr Code + use_your_points: Punkte einlösen diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml new file mode 100644 index 0000000..21f73c9 --- /dev/null +++ b/src/Resources/translations/messages.en.yaml @@ -0,0 +1,232 @@ +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Add condition + amount_configuration: Amount configuration + amount_configuration_help: 'JSON, e.g. {"points": 1, "per_amount": 100} for per-amount, {"points": 50} for fixed, {"factor": 2} for multiplier, {"expression": "floor(basis / 100)"} for expression' + amount_expression: Amount expression + amount_expression_help: Used when the amount type is "Expression"; the "basis" variable holds the rule's claimed basis in minor units + amount_requires_order_trigger: The per-amount and multiplier types need an order basis and are only available for the built-in order trigger. + amount_type: Amount type + condition_configuration: Condition configuration + condition_configuration_help: 'JSON, e.g. {"amount": 5000} or {"taxons": ["t_shirts"]}' + condition_expression: Condition expression + condition_expression_help: Used when the condition type is "Expression"; the condition passes when the expression is truthy + condition_type: Condition type + expression_required: Expression mode needs an expression. + multiplier_is_order_scoped: Multiplier rules are order-scoped only; use expression mode for item-level logic. + scope_requires_order_trigger: Taxon and product scopes need order items and are only available for the built-in order trigger. + conditions: Conditions + conditions_match: Conditions match + conditions_match_all: All conditions must pass + conditions_match_any: At least one condition must pass + dry_run: Dry run + dry_run_help: Evaluate against live traffic and log what would be awarded to the audit list, without writing any points + ends_at: Ends at + scope: Scope + scope_configuration: Scope configuration + scope_configuration_help: 'JSON, e.g. {"taxons": ["t_shirts"]} or {"products": ["MUG"]}; leave empty for order scope' + scope_help: Product- or taxon-scoped rules claim their matching items exclusively; order-scoped rules earn on the remainder + scope_order: Whole order + scope_product: Specific products + scope_taxon: Specific taxons + stackable: Stackable + stackable_help: Stackable rules competing for the same basis all apply and sum; a non-stackable rule applies alone (highest priority wins) + starts_at: Starts at + trigger: Trigger + window_timezone_help: Entered and evaluated in the application's configured timezone + earning_rule.amount: + expression: Expression + fixed: Fixed points + multiplier: Multiplier + per_amount: Points per amount + earning_rule.condition: + cart_contains_taxon: Cart contains taxon + customer_group: Customer group + date_window: Date window + day_of_week: Day of week + expression: Expression + nth_order: Nth order + order_total_at_least: Order total at least + invalid_json: This value is not valid JSON. + program: + award_moment_order_fulfilled: When the order is fulfilled + award_moment_payment_paid: When the payment is fully paid + award_order_points_at: Award order points at + award_order_points_at_help: The single order-lifecycle moment points are awarded — one per program, never per rule + base_currency_help: Amounts are in minor units of the channel's base currency + clawback_policy: Clawback policy + clawback_policy_allow_negative: Allow negative balance (recovered from future earnings) + clawback_policy_clamp_to_zero: Clamp the balance to zero + earning_basis: Earning basis + earning_basis_help: What counts toward earning; the earning rate itself is defined by earning rules + earning_basis_items_total: Items total (excl. shipping) + earning_basis_order_total: Order total (incl. shipping) + include_taxes: Include taxes in the basis + max_redeem_percent_of_order: Max. redeemable percent of order + max_redeem_percent_of_order_help: The share of the order items total coverable by points (100 allows fully free orders) + min_redeem_points: Minimum points per redemption + points_expiry_days: Points expire after (days) + points_expiry_days_help: Leave empty so points never expire. Note that expiring stored value is subject to consumer-law constraints in some jurisdictions + redemption_conversion_amount: '... are worth this amount (minor units)' + redemption_conversion_help: 'The redemption conversion: this many points...' + redemption_conversion_points: Points unit + retroactive_guest_points: Retroactive guest points + retroactive_guest_points_help: Award points for pre-registration guest orders when the guest registers (no backfill for earlier registrations) + rounding: Rounding + show_earnable_in_cart: Show the earn hint in the cart + show_earnable_on_product: Show the earn hint on product pages + tier_downgrade_grace_days: Tier downgrade grace (days) + tier_downgrade_grace_days_help: How long an account keeps its tier after falling below the threshold; 0 downgrades at the next nightly evaluation + tier_evaluation_window: Tier evaluation window + tier_window_calendar_year: Calendar year + tier_window_lifetime: Lifetime + tier_window_rolling_12_months: Rolling 12 months + rounding_ceil: Ceiling + rounding_floor: Floor + rounding_round: Round + promotion_rule: + customer_loyalty_tier: Customer's loyalty tier is at least + minimum_tier: Minimum tier + tier: + benefits: Benefits + benefits_description: Benefits description + color: Badge color + earning_multiplier: Earning multiplier + earning_multiplier_help: Applied to every points earn while the account is in this tier (1 = no change) + position_help: Higher positions are higher tiers; the highest position is the top tier + qualification_basis: Qualification basis + threshold: Threshold + threshold_help: The unit depends on the basis (points, minor currency units, or orders) + tier_basis: + amount_spent: Amount spent + orders_count: Orders placed + points_earned: Points earned + unit: + currency: minor currency units + orders: orders + points: points + trigger: + customer_birthday: Customer birthday + customer_registered: Customer registered + order_eligible: Order (built-in) + product_review_approved: Product review approved + ui: + account: Loyalty account + account_inactive: Your loyalty account is currently inactive — your balance is preserved, but earning and redeeming are paused. + accounts: Loyalty accounts + accounts_card_description: Browse accounts, inspect ledgers, adjust points + adjust: Adjust + anonymized: Anonymized + balance: Your points balance + balance_label: Balance + change_on_cart: Change on cart + consumptions: Consumed by + dashboard_subtitle: Points, earning rules, and program settings + derived_balance: Ledger sum + dry_run: Dry run + dry_run_results: Dry-run results + dry_run_results_card_description: What dry-run rules would have awarded + earn_hint_cart: 'This order earns ~%points% points.' + earn_hint_product: 'You will earn %points% points buying this product.' + earning_rate_hint: 'The earning rate is defined by earning rules: a base order-scoped rule with the per-amount type is the shop''s default rate.' + earning_rules: Earning rules + earning_rules_card_description: Triggers, conditions, and amounts + edit_earning_rule: Edit earning rule + edit_program: Edit program + new_earning_rule: New earning rule + description: Description + expired: Expired + expires: 'expires %date%' + expires_at: Expires at + expiring_soon: '%points% points expire before %date%.' + history: Transaction history + history_description: + clawback: 'Points reclaimed for a cancelled or refunded order' + earn_action: 'Points earned' + earn_order: 'Earned on order %order%' + earn_referral: 'Referral reward' + expire: 'Points expired' + manual_credit: 'Adjustment: %reason%' + manual_debit: 'Adjustment: %reason%' + redeem: 'Redeemed on order %order%' + redeem_rollback: 'Points restored from a cancelled order' + history_empty: No transactions yet — points you earn and spend will show up here. + my_loyalty: My loyalty + running_balance: Balance + inspect: Inspect + invariants_hold: All ledger invariants hold. + invariants_violated: Ledger invariants are violated — investigate before making manual corrections. + ledger: Ledger + lifetime_earned: Lifetime earned + lot: Lot + lots: Lots (replay-derived) + loyalty: Loyalty + manual_adjustment: Manual adjustment + manual_reason: + correction: Correction + goodwill: Goodwill + other: Other + promotion: Promotion + never: never + note: Note + points: Points + points_amount: '%points% points' + points_applied: '%points% points applied' + points_redemption: Points redemption + program: Program + program_card_description: Per-channel conversion, expiry, and policies + reason: Reason + refer_a_friend: Refer a friend + referee: Referee + referral_explainer: Share your link — when a friend places their first order, you both earn points. + referral_override: Approve anyway + referral_stats: 'You have %count% rewarded referral(s), earning %points% points.' + referral_status: + expired: Expired + pending: Pending + qualified: Qualified + rejected: Rejected + rewarded: Rewarded + referrals: Referrals + referrals_card_description: Referral statuses and fraud overrides + referrer: Referrer + redemption_clamped: 'Your request of %requested% points is only partially applied to this order; it grows back automatically when the order allows it.' + remaining: Remaining + remove_redemption: Remove + rule: Rule + rule_tester: Rule tester + rule_tester_card_description: Evaluate rules against a recent order, optionally at a different date + scope: Scope + share: Share + copied: Copied! + stat_accounts: Accounts + stat_active_accounts_90_days: Active accounts (90 days) + stat_liability: Outstanding points liability + stat_redemption_rate_90_days: Redemption rate (90 days) + stat_earned_30_days: Points earned (30 days) + stat_redeemed_30_days: Points redeemed (30 days) + tester_applied: Applied + tester_claimed_basis: Claimed basis + tester_claimed_by: Claimed by + tester_evaluate: Evaluate + tester_evaluate_at: Evaluate as if it were + tester_evaluate_at_help: Preview scheduled rules before their window opens + tester_failed_conditions: Failed conditions + tester_final_award: 'Final award: %points% points' + tester_invalid_date: The evaluation date is invalid. + tester_item_claims: Per-item claims + tester_matched: Matched + tester_order_has_no_customer: The order has no customer, so no rules can be evaluated. + tester_order_not_found: No order with that number was found. + tester_order_number: Order number + tester_rules: Rule evaluations + tier: Tier + tier_progress: '%metric% / %threshold% to %tier%' + tier_top: You have reached the top tier — thank you for being one of our very best customers! + tiers: Tiers + tiers_card_description: Levels, thresholds, multipliers, and benefits + trigger: Trigger + use_max: Use max + your_code: Your code + use_your_points: Use your points diff --git a/src/Resources/translations/messages.es.yaml b/src/Resources/translations/messages.es.yaml new file mode 100644 index 0000000..3cbc2ca --- /dev/null +++ b/src/Resources/translations/messages.es.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Añadir condición + amount_configuration: Configuración del importe + amount_configuration_help: 'JSON, p. ej. {"points": 1, "per_amount": 100} para por importe, {"points": 50} para fijo, {"factor": 2} para multiplicador, {"expression": "floor(basis / 100)"} para expresión' + amount_expression: Expresión del importe + amount_expression_help: Se usa cuando el tipo de importe es «Expresión»; la variable «basis» contiene la base reclamada por la regla en unidades menores + amount_requires_order_trigger: Los tipos por importe y multiplicador necesitan una base de pedido y solo están disponibles para el disparador de pedido integrado. + amount_type: Tipo de importe + condition_configuration: Configuración de la condición + condition_configuration_help: 'JSON, p. ej. {"amount": 5000} o {"taxons": ["t_shirts"]}' + condition_expression: Expresión de la condición + condition_expression_help: Se usa cuando el tipo de condición es «Expresión»; la condición se cumple cuando la expresión es verdadera + condition_type: Tipo de condición + expression_required: El modo expresión necesita una expresión. + multiplier_is_order_scoped: Las reglas de multiplicador se aplican solo a nivel de pedido; usa el modo expresión para lógica a nivel de artículo. + scope_requires_order_trigger: Los ámbitos de taxón y producto necesitan artículos de pedido y solo están disponibles para el disparador de pedido integrado. + conditions: Condiciones + conditions_match: Coincidencia de condiciones + conditions_match_all: Todas las condiciones deben cumplirse + conditions_match_any: Al menos una condición debe cumplirse + dry_run: Simulación + dry_run_help: Evaluar sobre el tráfico real y registrar lo que se habría otorgado en la lista de auditoría, sin acreditar ningún punto + ends_at: Termina el + scope: Ámbito + scope_configuration: Configuración del ámbito + scope_configuration_help: 'JSON, p. ej. {"taxons": ["t_shirts"]} o {"products": ["MUG"]}; dejar vacío para el ámbito de pedido' + scope_help: Las reglas limitadas a producto o taxón reclaman en exclusiva sus artículos coincidentes; las reglas a nivel de pedido ganan sobre el resto + scope_order: Pedido completo + scope_product: Productos específicos + scope_taxon: Taxones específicos + stackable: Acumulable + stackable_help: Las reglas acumulables que compiten por la misma base se aplican todas y se suman; una regla no acumulable se aplica sola (gana la de mayor prioridad) + starts_at: Comienza el + trigger: Disparador + window_timezone_help: Se introduce y evalúa en la zona horaria configurada de la aplicación + earning_rule.amount: + expression: Expresión + fixed: Puntos fijos + multiplier: Multiplicador + per_amount: Puntos por importe + earning_rule.condition: + cart_contains_taxon: El carrito contiene un taxón + customer_group: Grupo de clientes + date_window: Periodo de fechas + day_of_week: Día de la semana + expression: Expresión + nth_order: Enésimo pedido + order_total_at_least: Total del pedido como mínimo + invalid_json: Este valor no es un JSON válido. + program: + award_moment_order_fulfilled: Cuando el pedido se completa + award_moment_payment_paid: Cuando el pago está totalmente abonado + award_order_points_at: Otorgar los puntos del pedido en + award_order_points_at_help: El único momento del ciclo de vida del pedido en que se otorgan los puntos — uno por programa, nunca por regla + base_currency_help: Los importes están en unidades menores de la moneda base del canal + clawback_policy: Política de recuperación + clawback_policy_allow_negative: Permitir saldo negativo (se recupera de ganancias futuras) + clawback_policy_clamp_to_zero: Limitar el saldo a cero + earning_basis: Base de acumulación + earning_basis_help: Lo que cuenta para acumular puntos; la tasa de acumulación en sí se define mediante reglas de acumulación + earning_basis_items_total: Total de artículos (sin envío) + earning_basis_order_total: Total del pedido (envío incluido) + include_taxes: Incluir impuestos en la base + max_redeem_percent_of_order: Porcentaje máx. canjeable del pedido + max_redeem_percent_of_order_help: La parte del total de artículos del pedido que puede cubrirse con puntos (100 permite pedidos totalmente gratuitos) + min_redeem_points: Puntos mínimos por canje + points_expiry_days: Los puntos caducan después de (días) + points_expiry_days_help: Dejar vacío para que los puntos no caduquen nunca. Ten en cuenta que la caducidad del saldo acumulado está sujeta a la normativa de consumo en algunas jurisdicciones + redemption_conversion_amount: '... valen este importe (unidades menores)' + redemption_conversion_help: 'La conversión de canje: esta cantidad de puntos...' + redemption_conversion_points: Unidad de puntos + retroactive_guest_points: Puntos retroactivos de invitado + retroactive_guest_points_help: Otorgar puntos por los pedidos realizados como invitado antes del registro cuando el invitado se registra (sin efecto retroactivo para registros anteriores) + rounding: Redondeo + show_earnable_in_cart: Mostrar el aviso de puntos en el carrito + show_earnable_on_product: Mostrar el aviso de puntos en las páginas de producto + tier_downgrade_grace_days: Gracia antes de bajar de nivel (días) + tier_downgrade_grace_days_help: Cuánto tiempo conserva una cuenta su nivel tras caer por debajo del umbral; 0 baja de nivel en la siguiente evaluación nocturna + tier_evaluation_window: Ventana de evaluación de niveles + tier_window_calendar_year: Año natural + tier_window_lifetime: De por vida + tier_window_rolling_12_months: 12 meses móviles + rounding_ceil: Hacia arriba + rounding_floor: Hacia abajo + rounding_round: Al más cercano + promotion_rule: + customer_loyalty_tier: El nivel de fidelidad del cliente es al menos + minimum_tier: Nivel mínimo + tier: + benefits: Beneficios + benefits_description: Descripción de los beneficios + color: Color de la insignia + earning_multiplier: Multiplicador de acumulación + earning_multiplier_help: Se aplica a cada acumulación de puntos mientras la cuenta está en este nivel (1 = sin cambios) + position_help: Las posiciones más altas son niveles más altos; la posición más alta es el nivel superior + qualification_basis: Base de cualificación + threshold: Umbral + threshold_help: La unidad depende de la base (puntos, unidades menores de moneda o pedidos) + tier_basis: + amount_spent: Importe gastado + orders_count: Pedidos realizados + points_earned: Puntos ganados + unit: + currency: unidades menores de moneda + orders: pedidos + points: puntos + trigger: + customer_birthday: Cumpleaños del cliente + customer_registered: Cliente registrado + order_eligible: Pedido (integrado) + product_review_approved: Reseña de producto aprobada + ui: + account: Cuenta de fidelidad + account_inactive: Tu cuenta de fidelidad está actualmente inactiva — tu saldo se conserva, pero la acumulación y el canje están en pausa. + accounts: Cuentas de fidelidad + accounts_card_description: Explorar cuentas, inspeccionar libros mayores, ajustar puntos + adjust: Ajustar + anonymized: Anonimizado + balance: Tu saldo de puntos + balance_label: Saldo + change_on_cart: Cambiar en el carrito + consumptions: Consumido por + dashboard_subtitle: Puntos, reglas de acumulación y configuración del programa + derived_balance: Suma del libro mayor + dry_run: Simulación + dry_run_results: Resultados de simulación + dry_run_results_card_description: Lo que las reglas en simulación habrían otorgado + earn_hint_cart: 'Este pedido acumula ~%points% puntos.' + earn_hint_product: 'Ganarás %points% puntos al comprar este producto.' + earning_rate_hint: 'La tasa de acumulación se define mediante reglas de acumulación: una regla base a nivel de pedido con el tipo «puntos por importe» es la tasa predeterminada de la tienda.' + earning_rules: Reglas de acumulación + earning_rules_card_description: Disparadores, condiciones e importes + edit_earning_rule: Editar regla de acumulación + edit_program: Editar programa + new_earning_rule: Nueva regla de acumulación + description: Descripción + expired: Caducado + expires: 'caduca el %date%' + expires_at: Caduca el + expiring_soon: '%points% puntos caducan antes del %date%.' + history: Historial de transacciones + history_description: + clawback: 'Puntos recuperados por un pedido cancelado o reembolsado' + earn_action: 'Puntos ganados' + earn_order: 'Ganados en el pedido %order%' + earn_referral: 'Recompensa por recomendación' + expire: 'Puntos caducados' + manual_credit: 'Ajuste: %reason%' + manual_debit: 'Ajuste: %reason%' + redeem: 'Canjeados en el pedido %order%' + redeem_rollback: 'Puntos restaurados de un pedido cancelado' + history_empty: Aún no hay transacciones — los puntos que ganes y gastes aparecerán aquí. + my_loyalty: Mi fidelidad + running_balance: Saldo + inspect: Inspeccionar + invariants_hold: Todos los invariantes del libro mayor se cumplen. + invariants_violated: Los invariantes del libro mayor están violados — investiga antes de hacer correcciones manuales. + ledger: Libro mayor + lifetime_earned: Ganado en total + lot: Lote + lots: Lotes (derivados por reproducción) + loyalty: Fidelidad + manual_adjustment: Ajuste manual + manual_reason: + correction: Corrección + goodwill: Cortesía + other: Otro + promotion: Promoción + never: nunca + note: Nota + points: Puntos + points_amount: '%points% puntos' + points_applied: '%points% puntos aplicados' + points_redemption: Canje de puntos + program: Programa + program_card_description: Conversión, caducidad y políticas por canal + reason: Motivo + refer_a_friend: Recomienda a un amigo + referee: Recomendado + referral_explainer: Comparte tu enlace — cuando un amigo haga su primer pedido, los dos ganáis puntos. + referral_override: Aprobar de todos modos + referral_stats: 'Tienes %count% recomendación(es) recompensada(s), que han generado %points% puntos.' + referral_status: + expired: Caducada + pending: Pendiente + qualified: Cualificada + rejected: Rechazada + rewarded: Recompensada + referrals: Recomendaciones + referrals_card_description: Estados de las recomendaciones y anulación de rechazos por fraude + referrer: Recomendante + redemption_clamped: 'Tu solicitud de %requested% puntos solo se aplica parcialmente a este pedido; volverá a aumentar automáticamente cuando el pedido lo permita.' + remaining: Restante + remove_redemption: Quitar + rule: Regla + rule_tester: Probador de reglas + rule_tester_card_description: Evaluar reglas sobre un pedido reciente, opcionalmente en otra fecha + scope: Ámbito + share: Compartir + copied: ¡Copiado! + stat_accounts: Cuentas + stat_active_accounts_90_days: Cuentas activas (90 días) + stat_liability: Pasivo de puntos pendiente + stat_redemption_rate_90_days: Tasa de canje (90 días) + stat_earned_30_days: Puntos ganados (30 días) + stat_redeemed_30_days: Puntos canjeados (30 días) + tester_applied: Aplicado + tester_claimed_basis: Base reclamada + tester_claimed_by: Reclamado por + tester_evaluate: Evaluar + tester_evaluate_at: Evaluar como si fuera + tester_evaluate_at_help: Previsualizar las reglas programadas antes de que se abra su periodo + tester_failed_conditions: Condiciones no cumplidas + tester_final_award: 'Otorgamiento final: %points% puntos' + tester_invalid_date: La fecha de evaluación no es válida. + tester_item_claims: Reclamaciones por artículo + tester_matched: Coincide + tester_order_has_no_customer: El pedido no tiene cliente, por lo que no se puede evaluar ninguna regla. + tester_order_not_found: No se encontró ningún pedido con ese número. + tester_order_number: Número de pedido + tester_rules: Evaluaciones de reglas + tier: Nivel + tier_progress: '%metric% / %threshold% para %tier%' + tier_top: Has alcanzado el nivel más alto — ¡gracias por ser uno de nuestros mejores clientes! + tiers: Niveles + tiers_card_description: Niveles, umbrales, multiplicadores y beneficios + trigger: Disparador + use_max: Usar el máximo + your_code: Tu código + use_your_points: Usa tus puntos diff --git a/src/Resources/translations/messages.fi.yaml b/src/Resources/translations/messages.fi.yaml new file mode 100644 index 0000000..3110ffc --- /dev/null +++ b/src/Resources/translations/messages.fi.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Lisää ehto + amount_configuration: Määrän asetukset + amount_configuration_help: 'JSON, esim. {"points": 1, "per_amount": 100} summaperusteiselle, {"points": 50} kiinteälle, {"factor": 2} kertoimelle, {"expression": "floor(basis / 100)"} lausekkeelle' + amount_expression: Määrälauseke + amount_expression_help: Käytetään, kun määrätyyppi on "Lauseke"; muuttuja "basis" sisältää säännön varaaman perusteen pienimmissä yksiköissä + amount_requires_order_trigger: Summaperusteinen ja kerroin-tyypit tarvitsevat tilausperusteen ja ovat käytettävissä vain sisäänrakennetulle tilauslaukaisimelle. + amount_type: Määrätyyppi + condition_configuration: Ehdon asetukset + condition_configuration_help: 'JSON, esim. {"amount": 5000} tai {"taxons": ["t_shirts"]}' + condition_expression: Ehtolauseke + condition_expression_help: Käytetään, kun ehtotyyppi on "Lauseke"; ehto täyttyy, kun lauseke on tosi + condition_type: Ehtotyyppi + expression_required: Lauseketila vaatii lausekkeen. + multiplier_is_order_scoped: Kerroinsäännöt koskevat vain koko tilausta; käytä lauseketilaa rivikohtaiseen logiikkaan. + scope_requires_order_trigger: Taksoni- ja tuoterajaukset tarvitsevat tilausrivejä ja ovat käytettävissä vain sisäänrakennetulle tilauslaukaisimelle. + conditions: Ehdot + conditions_match: Ehtojen täsmäys + conditions_match_all: Kaikkien ehtojen on täytyttävä + conditions_match_any: Vähintään yhden ehdon on täytyttävä + dry_run: Koeajo + dry_run_help: Arvioi todellista liikennettä vastaan ja kirjaa tarkastuslistalle, mitä olisi myönnetty, kirjoittamatta yhtään pistettä + ends_at: Päättyy + scope: Rajaus + scope_configuration: Rajauksen asetukset + scope_configuration_help: 'JSON, esim. {"taxons": ["t_shirts"]} tai {"products": ["MUG"]}; jätä tyhjäksi koko tilauksen rajaukselle' + scope_help: Tuote- tai taksonirajatut säännöt varaavat täsmäävät rivinsä yksinoikeudella; koko tilaukseen rajatut säännöt kerryttävät pisteitä jäljelle jäävästä osasta + scope_order: Koko tilaus + scope_product: Tietyt tuotteet + scope_taxon: Tietyt taksonit + stackable: Yhdisteltävä + stackable_help: Samasta perusteesta kilpailevat yhdisteltävät säännöt pätevät kaikki ja lasketaan yhteen; ei-yhdisteltävä sääntö pätee yksin (korkein prioriteetti voittaa) + starts_at: Alkaa + trigger: Laukaisin + window_timezone_help: Syötetään ja arvioidaan sovelluksen määritetyllä aikavyöhykkeellä + earning_rule.amount: + expression: Lauseke + fixed: Kiinteät pisteet + multiplier: Kerroin + per_amount: Pisteitä summaa kohden + earning_rule.condition: + cart_contains_taxon: Ostoskori sisältää taksonin + customer_group: Asiakasryhmä + date_window: Aikaväli + day_of_week: Viikonpäivä + expression: Lauseke + nth_order: Monesko tilaus + order_total_at_least: Tilauksen summa vähintään + invalid_json: Tämä arvo ei ole kelvollista JSONia. + program: + award_moment_order_fulfilled: Kun tilaus on toimitettu + award_moment_payment_paid: Kun maksu on kokonaan maksettu + award_order_points_at: Tilauspisteiden myöntöhetki + award_order_points_at_help: Tilauksen elinkaaren ainoa hetki, jolloin pisteet myönnetään — yksi ohjelmaa kohden, ei koskaan sääntöä kohden + base_currency_help: Summat ovat kanavan perusvaluutan pienimmissä yksiköissä + clawback_policy: Takaisinperintäkäytäntö + clawback_policy_allow_negative: Salli negatiivinen saldo (peritään tulevista kertymistä) + clawback_policy_clamp_to_zero: Rajaa saldo nollaan + earning_basis: Kertymisperuste + earning_basis_help: Mikä lasketaan mukaan kertymään; itse kertymistahti määritellään kertymissäännöillä + earning_basis_items_total: Tuotteiden summa (ilman toimitusta) + earning_basis_order_total: Tilauksen summa (sis. toimituksen) + include_taxes: Sisällytä verot perusteeseen + max_redeem_percent_of_order: Tilauksen enimmäislunastusprosentti + max_redeem_percent_of_order_help: Osuus tilauksen tuotteiden summasta, joka voidaan kattaa pisteillä (100 sallii täysin ilmaiset tilaukset) + min_redeem_points: Pisteiden vähimmäismäärä lunastusta kohden + points_expiry_days: Pisteet vanhenevat (päivää) + points_expiry_days_help: Jätä tyhjäksi, jolloin pisteet eivät koskaan vanhene. Huomaa, että talletetun arvon vanheneminen voi olla kuluttajansuojalainsäädännön rajoitusten alaista joillakin lainkäyttöalueilla + redemption_conversion_amount: '... ovat tämän summan arvoisia (pienimmissä yksiköissä)' + redemption_conversion_help: 'Lunastuskurssi: näin monta pistettä...' + redemption_conversion_points: Pisteyksikkö + retroactive_guest_points: Takautuvat vieraspisteet + retroactive_guest_points_help: Myönnä pisteet ennen rekisteröitymistä tehdyistä vierastilauksista, kun vieras rekisteröityy (ei takautuvaa hyvitystä aiemmille rekisteröitymisille) + rounding: Pyöristys + show_earnable_in_cart: Näytä pistevihje ostoskorissa + show_earnable_on_product: Näytä pistevihje tuotesivuilla + tier_downgrade_grace_days: Tason pudotuksen lykkäys (päivää) + tier_downgrade_grace_days_help: Kuinka kauan tili säilyttää tasonsa pudottuaan kynnyksen alle; 0 pudottaa tason seuraavassa öisessä arvioinnissa + tier_evaluation_window: Tasojen arviointijakso + tier_window_calendar_year: Kalenterivuosi + tier_window_lifetime: Koko asiakkuuden ajalta + tier_window_rolling_12_months: Liukuva 12 kuukautta + rounding_ceil: Ylöspäin + rounding_floor: Alaspäin + rounding_round: Lähimpään + promotion_rule: + customer_loyalty_tier: Asiakkaan kanta-asiakastaso on vähintään + minimum_tier: Vähimmäistaso + tier: + benefits: Edut + benefits_description: Etujen kuvaus + color: Merkin väri + earning_multiplier: Kertymiskerroin + earning_multiplier_help: Sovelletaan kaikkeen pisteiden kertymiseen, kun tili on tällä tasolla (1 = ei muutosta) + position_help: Suuremmat sijainnit ovat korkeampia tasoja; suurin sijainti on ylin taso + qualification_basis: Kelpoisuusperuste + threshold: Kynnys + threshold_help: Yksikkö riippuu perusteesta (pisteet, valuutan pienimmät yksiköt tai tilaukset) + tier_basis: + amount_spent: Käytetty rahamäärä + orders_count: Tehdyt tilaukset + points_earned: Kertyneet pisteet + unit: + currency: valuutan pienimmät yksiköt + orders: tilausta + points: pistettä + trigger: + customer_birthday: Asiakkaan syntymäpäivä + customer_registered: Asiakas rekisteröitynyt + order_eligible: Tilaus (sisäänrakennettu) + product_review_approved: Tuotearvostelu hyväksytty + ui: + account: Kanta-asiakastili + account_inactive: Kanta-asiakastilisi ei ole tällä hetkellä käytössä — saldosi säilyy, mutta pisteiden kerryttäminen ja lunastaminen on keskeytetty. + accounts: Kanta-asiakastilit + accounts_card_description: Selaa tilejä, tarkastele kirjanpitoa, muokkaa pisteitä + adjust: Muokkaa + anonymized: Anonymisoitu + balance: Pistesaldosi + balance_label: Saldo + change_on_cart: Muuta ostoskorissa + consumptions: Kulutettu + dashboard_subtitle: Pisteet, kertymissäännöt ja ohjelman asetukset + derived_balance: Kirjanpidon summa + dry_run: Koeajo + dry_run_results: Koeajon tulokset + dry_run_results_card_description: Mitä koeajosäännöt olisivat myöntäneet + earn_hint_cart: 'Tästä tilauksesta kertyy ~%points% pistettä.' + earn_hint_product: 'Ansaitset %points% pistettä ostamalla tämän tuotteen.' + earning_rate_hint: 'Kertymistahti määritellään kertymissäännöillä: koko tilaukseen rajattu perussääntö summaperusteisella tyypillä on kaupan oletustahti.' + earning_rules: Kertymissäännöt + earning_rules_card_description: Laukaisimet, ehdot ja määrät + edit_earning_rule: Muokkaa kertymissääntöä + edit_program: Muokkaa ohjelmaa + new_earning_rule: Uusi kertymissääntö + description: Kuvaus + expired: Vanhentunut + expires: 'vanhenee %date%' + expires_at: Vanhenee + expiring_soon: '%points% pistettä vanhenee ennen %date%.' + history: Tapahtumahistoria + history_description: + clawback: 'Pisteet peritty takaisin peruutetusta tai hyvitetystä tilauksesta' + earn_action: 'Pisteitä ansaittu' + earn_order: 'Ansaittu tilauksesta %order%' + earn_referral: 'Suosittelupalkkio' + expire: 'Pisteet vanhentuneet' + manual_credit: 'Oikaisu: %reason%' + manual_debit: 'Oikaisu: %reason%' + redeem: 'Lunastettu tilaukseen %order%' + redeem_rollback: 'Pisteet palautettu peruutetusta tilauksesta' + history_empty: Ei vielä tapahtumia — ansaitsemasi ja käyttämäsi pisteet näkyvät täällä. + my_loyalty: Kanta-asiakkuuteni + running_balance: Saldo + inspect: Tarkastele + invariants_hold: Kaikki kirjanpidon invariantit pitävät. + invariants_violated: Kirjanpidon invariantteja on rikottu — selvitä asia ennen manuaalisia korjauksia. + ledger: Kirjanpito + lifetime_earned: Ansaittu yhteensä + lot: Erä + lots: Erät (johdettu toistamalla) + loyalty: Kanta-asiakkuus + manual_adjustment: Manuaalinen oikaisu + manual_reason: + correction: Korjaus + goodwill: Hyvitys + other: Muu + promotion: Kampanja + never: ei koskaan + note: Huomautus + points: Pisteet + points_amount: '%points% pistettä' + points_applied: '%points% pistettä käytetty' + points_redemption: Pisteiden lunastus + program: Ohjelma + program_card_description: Kanavakohtainen muuntokurssi, vanheneminen ja käytännöt + reason: Syy + refer_a_friend: Suosittele ystävälle + referee: Suositeltu + referral_explainer: Jaa linkkisi — kun ystäväsi tekee ensimmäisen tilauksensa, ansaitsette molemmat pisteitä. + referral_override: Hyväksy silti + referral_stats: 'Sinulla on %count% palkittua suosittelua, joista on kertynyt %points% pistettä.' + referral_status: + expired: Vanhentunut + pending: Odottaa + qualified: Kelpuutettu + rejected: Hylätty + rewarded: Palkittu + referrals: Suosittelut + referrals_card_description: Suosittelujen tilat ja petostarkistusten ohitukset + referrer: Suosittelija + redemption_clamped: 'Pyytämästäsi %requested% pisteestä vain osa on käytetty tähän tilaukseen; määrä palautuu automaattisesti, kun tilaus sen sallii.' + remaining: Jäljellä + remove_redemption: Poista + rule: Sääntö + rule_tester: Sääntötestaaja + rule_tester_card_description: Arvioi sääntöjä äskettäistä tilausta vastaan, halutessasi eri päivämäärällä + scope: Rajaus + share: Jaa + copied: Kopioitu! + stat_accounts: Tilit + stat_active_accounts_90_days: Aktiiviset tilit (90 päivää) + stat_liability: Avoin pistevastuu + stat_redemption_rate_90_days: Lunastusaste (90 päivää) + stat_earned_30_days: Ansaitut pisteet (30 päivää) + stat_redeemed_30_days: Lunastetut pisteet (30 päivää) + tester_applied: Sovellettu + tester_claimed_basis: Varattu peruste + tester_claimed_by: Varannut + tester_evaluate: Arvioi + tester_evaluate_at: Arvioi ikään kuin olisi + tester_evaluate_at_help: Esikatsele ajastettuja sääntöjä ennen niiden aikaikkunan avautumista + tester_failed_conditions: Täyttymättömät ehdot + tester_final_award: 'Lopullinen myöntö: %points% pistettä' + tester_invalid_date: Arviointipäivämäärä on virheellinen. + tester_item_claims: Rivikohtaiset varaukset + tester_matched: Täsmäsi + tester_order_has_no_customer: Tilauksella ei ole asiakasta, joten sääntöjä ei voida arvioida. + tester_order_not_found: Tilausta tällä numerolla ei löytynyt. + tester_order_number: Tilausnumero + tester_rules: Sääntöjen arvioinnit + tier: Taso + tier_progress: '%metric% / %threshold% tasoon %tier%' + tier_top: Olet saavuttanut ylimmän tason — kiitos, että olet yksi parhaista asiakkaistamme! + tiers: Tasot + tiers_card_description: Tasot, kynnykset, kertoimet ja edut + trigger: Laukaisin + use_max: Käytä enimmäismäärä + your_code: Koodisi + use_your_points: Käytä pisteesi diff --git a/src/Resources/translations/messages.fr.yaml b/src/Resources/translations/messages.fr.yaml new file mode 100644 index 0000000..4284445 --- /dev/null +++ b/src/Resources/translations/messages.fr.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Ajouter une condition + amount_configuration: Configuration du montant + amount_configuration_help: 'JSON, p. ex. {"points": 1, "per_amount": 100} pour un montant proportionnel, {"points": 50} pour un montant fixe, {"factor": 2} pour un multiplicateur, {"expression": "floor(basis / 100)"} pour une expression' + amount_expression: Expression du montant + amount_expression_help: Utilisée lorsque le type de montant est « Expression » ; la variable « basis » contient la base revendiquée par la règle, en unités mineures + amount_requires_order_trigger: Les types proportionnel et multiplicateur nécessitent une base de commande et ne sont disponibles que pour le déclencheur de commande intégré. + amount_type: Type de montant + condition_configuration: Configuration de la condition + condition_configuration_help: 'JSON, p. ex. {"amount": 5000} ou {"taxons": ["t_shirts"]}' + condition_expression: Expression de la condition + condition_expression_help: 'Utilisée lorsque le type de condition est « Expression » ; la condition est remplie lorsque l''expression est vraie' + condition_type: Type de condition + expression_required: Le mode expression nécessite une expression. + multiplier_is_order_scoped: 'Les règles multiplicatrices s''appliquent uniquement à la commande entière ; utilisez le mode expression pour une logique au niveau des articles.' + scope_requires_order_trigger: Les portées taxon et produit nécessitent des articles de commande et ne sont disponibles que pour le déclencheur de commande intégré. + conditions: Conditions + conditions_match: Correspondance des conditions + conditions_match_all: Toutes les conditions doivent être remplies + conditions_match_any: Au moins une condition doit être remplie + dry_run: Mode simulation + dry_run_help: 'Évaluer sur le trafic réel et consigner ce qui aurait été attribué dans la liste d''audit, sans créditer aucun point' + ends_at: Se termine le + scope: Portée + scope_configuration: Configuration de la portée + scope_configuration_help: 'JSON, p. ex. {"taxons": ["t_shirts"]} ou {"products": ["MUG"]} ; laisser vide pour la portée commande' + scope_help: Les règles limitées à un produit ou à un taxon revendiquent exclusivement leurs articles correspondants ; les règles au niveau de la commande gagnent sur le reste + scope_order: Commande entière + scope_product: Produits spécifiques + scope_taxon: Taxons spécifiques + stackable: Cumulable + stackable_help: 'Les règles cumulables en concurrence pour la même base s''appliquent toutes et s''additionnent ; une règle non cumulable s''applique seule (la priorité la plus élevée l''emporte)' + starts_at: Commence le + trigger: Déclencheur + window_timezone_help: 'Saisi et évalué dans le fuseau horaire configuré de l''application' + earning_rule.amount: + expression: Expression + fixed: Points fixes + multiplier: Multiplicateur + per_amount: Points par montant + earning_rule.condition: + cart_contains_taxon: Le panier contient un taxon + customer_group: Groupe de clients + date_window: Période de dates + day_of_week: Jour de la semaine + expression: Expression + nth_order: Nième commande + order_total_at_least: Total de commande minimum + invalid_json: 'Cette valeur n''est pas un JSON valide.' + program: + award_moment_order_fulfilled: Lorsque la commande est traitée + award_moment_payment_paid: Lorsque le paiement est entièrement réglé + award_order_points_at: Attribuer les points de commande au moment + award_order_points_at_help: Le moment unique du cycle de vie de la commande où les points sont attribués — un par programme, jamais par règle + base_currency_help: Les montants sont exprimés en unités mineures de la devise de base du canal + clawback_policy: Politique de récupération + clawback_policy_allow_negative: Autoriser un solde négatif (récupéré sur les gains futurs) + clawback_policy_clamp_to_zero: Limiter le solde à zéro + earning_basis: Base de gain + earning_basis_help: Ce qui compte pour le gain ; le taux de gain lui-même est défini par les règles de gain + earning_basis_items_total: Total des articles (hors livraison) + earning_basis_order_total: Total de la commande (livraison incluse) + include_taxes: Inclure les taxes dans la base + max_redeem_percent_of_order: Pourcentage max. de la commande utilisable + max_redeem_percent_of_order_help: La part du total des articles de la commande pouvant être couverte par des points (100 permet des commandes entièrement gratuites) + min_redeem_points: Points minimum par utilisation + points_expiry_days: Les points expirent après (jours) + points_expiry_days_help: 'Laisser vide pour que les points n''expirent jamais. Notez que l''expiration de valeur stockée est soumise à des contraintes de droit de la consommation dans certaines juridictions' + redemption_conversion_amount: '... valent ce montant (unités mineures)' + redemption_conversion_help: 'La conversion d''utilisation : ce nombre de points...' + redemption_conversion_points: Unité de points + retroactive_guest_points: Points invités rétroactifs + retroactive_guest_points_help: 'Attribuer des points pour les commandes passées en tant qu''invité avant l''inscription, lorsque l''invité s''inscrit (pas de rattrapage pour les inscriptions antérieures)' + rounding: Arrondi + show_earnable_in_cart: 'Afficher l''indication de gain dans le panier' + show_earnable_on_product: 'Afficher l''indication de gain sur les pages produit' + tier_downgrade_grace_days: Délai de grâce avant rétrogradation de niveau (jours) + tier_downgrade_grace_days_help: Durée pendant laquelle un compte conserve son niveau après être passé sous le seuil ; 0 rétrograde à la prochaine évaluation nocturne + tier_evaluation_window: 'Fenêtre d''évaluation des niveaux' + tier_window_calendar_year: Année civile + tier_window_lifetime: À vie + tier_window_rolling_12_months: 12 mois glissants + rounding_ceil: Arrondi supérieur + rounding_floor: Arrondi inférieur + rounding_round: Arrondi au plus proche + promotion_rule: + customer_loyalty_tier: Le niveau de fidélité du client est au moins + minimum_tier: Niveau minimum + tier: + benefits: Avantages + benefits_description: Description des avantages + color: Couleur du badge + earning_multiplier: Multiplicateur de gain + earning_multiplier_help: Appliqué à chaque gain de points tant que le compte est à ce niveau (1 = aucun changement) + position_help: Les positions plus élevées correspondent à des niveaux plus élevés ; la position la plus élevée est le niveau supérieur + qualification_basis: Base de qualification + threshold: Seuil + threshold_help: 'L''unité dépend de la base (points, unités mineures de devise ou commandes)' + tier_basis: + amount_spent: Montant dépensé + orders_count: Commandes passées + points_earned: Points gagnés + unit: + currency: unités mineures de devise + orders: commandes + points: points + trigger: + customer_birthday: Anniversaire du client + customer_registered: Client inscrit + order_eligible: Commande (intégré) + product_review_approved: Avis produit approuvé + ui: + account: Compte de fidélité + account_inactive: 'Votre compte de fidélité est actuellement inactif — votre solde est conservé, mais le gain et l''utilisation de points sont suspendus.' + accounts: Comptes de fidélité + accounts_card_description: Parcourir les comptes, inspecter les registres, ajuster les points + adjust: Ajuster + anonymized: Anonymisé + balance: Votre solde de points + balance_label: Solde + change_on_cart: Modifier sur le panier + consumptions: Consommé par + dashboard_subtitle: Points, règles de gain et paramètres du programme + derived_balance: Somme du registre + dry_run: Mode simulation + dry_run_results: Résultats de simulation + dry_run_results_card_description: Ce que les règles en mode simulation auraient attribué + earn_hint_cart: 'Cette commande rapporte ~%points% points.' + earn_hint_product: 'Vous gagnerez %points% points en achetant ce produit.' + earning_rate_hint: 'Le taux de gain est défini par les règles de gain : une règle de base au niveau de la commande avec le type « points par montant » constitue le taux par défaut de la boutique.' + earning_rules: Règles de gain + earning_rules_card_description: Déclencheurs, conditions et montants + edit_earning_rule: Modifier la règle de gain + edit_program: Modifier le programme + new_earning_rule: Nouvelle règle de gain + description: Description + expired: Expiré + expires: 'expire le %date%' + expires_at: Expire le + expiring_soon: '%points% points expirent avant le %date%.' + history: Historique des transactions + history_description: + clawback: 'Points récupérés pour une commande annulée ou remboursée' + earn_action: 'Points gagnés' + earn_order: 'Gagnés sur la commande %order%' + earn_referral: 'Récompense de parrainage' + expire: 'Points expirés' + manual_credit: 'Ajustement : %reason%' + manual_debit: 'Ajustement : %reason%' + redeem: 'Utilisés sur la commande %order%' + redeem_rollback: 'Points restitués suite à une commande annulée' + history_empty: 'Aucune transaction pour l''instant — les points que vous gagnez et dépensez apparaîtront ici.' + my_loyalty: Ma fidélité + running_balance: Solde + inspect: Inspecter + invariants_hold: Tous les invariants du registre sont respectés. + invariants_violated: Les invariants du registre sont violés — enquêtez avant toute correction manuelle. + ledger: Registre + lifetime_earned: Gagné au total + lot: Lot + lots: Lots (dérivés par rejeu) + loyalty: Fidélité + manual_adjustment: Ajustement manuel + manual_reason: + correction: Correction + goodwill: Geste commercial + other: Autre + promotion: Promotion + never: jamais + note: Note + points: Points + points_amount: '%points% points' + points_applied: '%points% points appliqués' + points_redemption: Utilisation de points + program: Programme + program_card_description: Conversion, expiration et politiques par canal + reason: Raison + refer_a_friend: Parrainez un ami + referee: Filleul + referral_explainer: 'Partagez votre lien — lorsqu''un ami passe sa première commande, vous gagnez tous les deux des points.' + referral_override: Approuver quand même + referral_stats: 'Vous avez %count% parrainage(s) récompensé(s), qui ont rapporté %points% points.' + referral_status: + expired: Expiré + pending: En attente + qualified: Qualifié + rejected: Rejeté + rewarded: Récompensé + referrals: Parrainages + referrals_card_description: Statuts des parrainages et dérogations antifraude + referrer: Parrain + redemption_clamped: 'Votre demande de %requested% points n''est appliquée que partiellement à cette commande ; elle augmentera automatiquement dès que la commande le permettra.' + remaining: Restant + remove_redemption: Retirer + rule: Règle + rule_tester: Testeur de règles + rule_tester_card_description: Évaluer les règles sur une commande récente, éventuellement à une autre date + scope: Portée + share: Partager + copied: Copié ! + stat_accounts: Comptes + stat_active_accounts_90_days: Comptes actifs (90 jours) + stat_liability: Passif de points en circulation + stat_redemption_rate_90_days: 'Taux d''utilisation (90 jours)' + stat_earned_30_days: Points gagnés (30 jours) + stat_redeemed_30_days: Points utilisés (30 jours) + tester_applied: Appliqué + tester_claimed_basis: Base revendiquée + tester_claimed_by: Revendiqué par + tester_evaluate: Évaluer + tester_evaluate_at: 'Évaluer comme si c''était' + tester_evaluate_at_help: 'Prévisualiser les règles planifiées avant l''ouverture de leur période' + tester_failed_conditions: Conditions non remplies + tester_final_award: 'Attribution finale : %points% points' + tester_invalid_date: 'La date d''évaluation est invalide.' + tester_item_claims: Revendications par article + tester_matched: Correspond + tester_order_has_no_customer: 'La commande n''a pas de client, aucune règle ne peut donc être évaluée.' + tester_order_not_found: 'Aucune commande avec ce numéro n''a été trouvée.' + tester_order_number: Numéro de commande + tester_rules: Évaluations des règles + tier: Niveau + tier_progress: '%metric% / %threshold% vers %tier%' + tier_top: 'Vous avez atteint le niveau le plus élevé — merci d''être l''un de nos tout meilleurs clients !' + tiers: Niveaux + tiers_card_description: Niveaux, seuils, multiplicateurs et avantages + trigger: Déclencheur + use_max: Utiliser le maximum + your_code: Votre code + use_your_points: Utilisez vos points diff --git a/src/Resources/translations/messages.hu.yaml b/src/Resources/translations/messages.hu.yaml new file mode 100644 index 0000000..0a303cb --- /dev/null +++ b/src/Resources/translations/messages.hu.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Feltétel hozzáadása + amount_configuration: Összeg beállítása + amount_configuration_help: 'JSON, pl. {"points": 1, "per_amount": 100} az összeg utáni ponthoz, {"points": 50} a fix pontszámhoz, {"factor": 2} a szorzóhoz, {"expression": "floor(basis / 100)"} a kifejezéshez' + amount_expression: Összeg kifejezése + amount_expression_help: Akkor használatos, ha az összeg típusa „Kifejezés”; a "basis" változó a szabály által lefoglalt alapot tartalmazza a pénznem legkisebb egységében + amount_requires_order_trigger: Az összeg utáni és a szorzó típushoz rendelési alap szükséges, ezért csak a beépített rendelési eseményindítóval érhetők el. + amount_type: Összeg típusa + condition_configuration: Feltétel beállítása + condition_configuration_help: 'JSON, pl. {"amount": 5000} vagy {"taxons": ["t_shirts"]}' + condition_expression: Feltétel kifejezése + condition_expression_help: Akkor használatos, ha a feltétel típusa „Kifejezés”; a feltétel akkor teljesül, ha a kifejezés igaz értékű + condition_type: Feltétel típusa + expression_required: A kifejezés módhoz kifejezés szükséges. + multiplier_is_order_scoped: A szorzós szabályok csak a teljes rendelésre vonatkoznak; tételszintű logikához használja a kifejezés módot. + scope_requires_order_trigger: A taxon- és termékhatókörhöz rendelési tételek szükségesek, ezért csak a beépített rendelési eseményindítóval érhetők el. + conditions: Feltételek + conditions_match: Feltételek teljesülése + conditions_match_all: Minden feltételnek teljesülnie kell + conditions_match_any: Legalább egy feltételnek teljesülnie kell + dry_run: Próbaüzem + dry_run_help: Kiértékelés az éles forgalmon, és annak naplózása az auditlistába, hogy mennyi pont járna — pontok jóváírása nélkül + ends_at: Vége + scope: Hatókör + scope_configuration: Hatókör beállítása + scope_configuration_help: 'JSON, pl. {"taxons": ["t_shirts"]} vagy {"products": ["MUG"]}; hagyja üresen a rendelés hatókörhöz' + scope_help: A termék- vagy taxonhatókörű szabályok kizárólagosan lefoglalják az egyező tételeket; a rendeléshatókörű szabályok a maradék után adnak pontot + scope_order: Teljes rendelés + scope_product: Adott termékek + scope_taxon: Adott taxonok + stackable: Halmozható + stackable_help: Az ugyanazon alapért versengő halmozható szabályok mind érvényesülnek és összeadódnak; a nem halmozható szabály egyedül érvényesül (a legmagasabb prioritás nyer) + starts_at: Kezdete + trigger: Eseményindító + window_timezone_help: Az alkalmazásban beállított időzóna szerint kell megadni, és aszerint kerül kiértékelésre + earning_rule.amount: + expression: Kifejezés + fixed: Fix pontszám + multiplier: Szorzó + per_amount: Pont összeg után + earning_rule.condition: + cart_contains_taxon: A kosár tartalmazza a taxont + customer_group: Vásárlói csoport + date_window: Időszak + day_of_week: A hét napja + expression: Kifejezés + nth_order: N-edik rendelés + order_total_at_least: Rendelés végösszege legalább + invalid_json: Ez az érték nem érvényes JSON. + program: + award_moment_order_fulfilled: Amikor a rendelés teljesült + award_moment_payment_paid: Amikor a fizetés teljes egészében megtörtént + award_order_points_at: Rendelési pontok jóváírása ekkor + award_order_points_at_help: A rendelés életciklusának egyetlen pillanata, amikor a pontok jóváírásra kerülnek — programonként egy, sosem szabályonként + base_currency_help: Az összegek a csatorna alappénznemének legkisebb egységében értendők + clawback_policy: Visszavonási szabályzat + clawback_policy_allow_negative: Negatív egyenleg engedélyezése (a jövőbeli pontszerzésből térül meg) + clawback_policy_clamp_to_zero: Az egyenleg nullára korlátozása + earning_basis: Pontszerzési alap + earning_basis_help: Mi számít bele a pontszerzésbe; magát a pontszerzési arányt a pontszerzési szabályok határozzák meg + earning_basis_items_total: Tételek összege (szállítás nélkül) + earning_basis_order_total: Rendelés végösszege (szállítással) + include_taxes: Adók beszámítása az alapba + max_redeem_percent_of_order: A rendelés max. beváltható százaléka + max_redeem_percent_of_order_help: A rendelési tételek összegének pontokkal fedezhető része (100 esetén a rendelés teljesen ingyenes lehet) + min_redeem_points: Minimális pontszám beváltásonként + points_expiry_days: A pontok lejárnak ennyi nap után + points_expiry_days_help: Hagyja üresen, ha a pontok soha nem járnak le. Vegye figyelembe, hogy a tárolt érték lejárata egyes joghatóságokban fogyasztóvédelmi korlátozások alá esik + redemption_conversion_amount: '... ennyit ér (legkisebb pénzegység)' + redemption_conversion_help: 'A beváltási átváltás: ennyi pont...' + redemption_conversion_points: Pontegység + retroactive_guest_points: Visszamenőleges vendégpontok + retroactive_guest_points_help: Pontok jóváírása a regisztráció előtti vendégrendelésekért, amikor a vendég regisztrál (korábbi regisztrációkra nincs visszamenőleges jóváírás) + rounding: Kerekítés + show_earnable_in_cart: Pontszerzési tipp megjelenítése a kosárban + show_earnable_on_product: Pontszerzési tipp megjelenítése a termékoldalakon + tier_downgrade_grace_days: Türelmi idő a szint visszaminősítése előtt (nap) + tier_downgrade_grace_days_help: Meddig tartja meg a fiók a szintjét, miután a küszöb alá esett; 0 esetén a következő éjszakai kiértékeléskor visszaminősül + tier_evaluation_window: Szintkiértékelési időszak + tier_window_calendar_year: Naptári év + tier_window_lifetime: Teljes élettartam + tier_window_rolling_12_months: Gördülő 12 hónap + rounding_ceil: Felfelé + rounding_floor: Lefelé + rounding_round: Matematikai + promotion_rule: + customer_loyalty_tier: A vásárló hűségszintje legalább + minimum_tier: Minimális szint + tier: + benefits: Előnyök + benefits_description: Előnyök leírása + color: Jelvény színe + earning_multiplier: Pontszerzési szorzó + earning_multiplier_help: Minden pontszerzésre alkalmazva, amíg a fiók ezen a szinten van (1 = nincs változás) + position_help: A magasabb pozíciók magasabb szintek; a legmagasabb pozíció a legfelső szint + qualification_basis: Minősítési alap + threshold: Küszöbérték + threshold_help: A mértékegység az alaptól függ (pontok, a pénznem legkisebb egységei vagy rendelések) + tier_basis: + amount_spent: Elköltött összeg + orders_count: Leadott rendelések + points_earned: Szerzett pontok + unit: + currency: a pénznem legkisebb egységei + orders: rendelés + points: pont + trigger: + customer_birthday: Vásárló születésnapja + customer_registered: Vásárló regisztrált + order_eligible: Rendelés (beépített) + product_review_approved: Termékértékelés jóváhagyva + ui: + account: Hűségfiók + account_inactive: Hűségfiókja jelenleg inaktív — egyenlege megmarad, de a pontszerzés és a beváltás szünetel. + accounts: Hűségfiókok + accounts_card_description: Fiókok böngészése, főkönyvek megtekintése, pontok módosítása + adjust: Módosítás + anonymized: Anonimizálva + balance: Az Ön pontegyenlege + balance_label: Egyenleg + change_on_cart: Módosítás a kosárban + consumptions: Felhasználta + dashboard_subtitle: Pontok, pontszerzési szabályok és programbeállítások + derived_balance: Főkönyvi összeg + dry_run: Próbaüzem + dry_run_results: Próbaüzem eredményei + dry_run_results_card_description: Amit a próbaüzemben lévő szabályok jóváírtak volna + earn_hint_cart: 'Ezzel a rendeléssel ~%points% pontot szerez.' + earn_hint_product: 'E termék megvásárlásával %points% pontot szerez.' + earning_rate_hint: 'A pontszerzési arányt a pontszerzési szabályok határozzák meg: egy rendeléshatókörű alapszabály az összeg utáni típussal a bolt alapértelmezett aránya.' + earning_rules: Pontszerzési szabályok + earning_rules_card_description: Eseményindítók, feltételek és összegek + edit_earning_rule: Pontszerzési szabály szerkesztése + edit_program: Program szerkesztése + new_earning_rule: Új pontszerzési szabály + description: Leírás + expired: Lejárt + expires: 'lejár: %date%' + expires_at: Lejárat + expiring_soon: '%points% pont lejár %date% előtt.' + history: Tranzakciós előzmények + history_description: + clawback: 'Törölt vagy visszatérített rendelés miatt visszavont pontok' + earn_action: 'Szerzett pontok' + earn_order: 'A(z) %order% rendelésen szerzett pontok' + earn_referral: 'Ajánlási jutalom' + expire: 'Lejárt pontok' + manual_credit: 'Módosítás: %reason%' + manual_debit: 'Módosítás: %reason%' + redeem: 'Beváltva a(z) %order% rendelésnél' + redeem_rollback: 'Törölt rendelésből visszaállított pontok' + history_empty: Még nincsenek tranzakciók — a megszerzett és elköltött pontok itt jelennek meg. + my_loyalty: Hűségprogramom + running_balance: Egyenleg + inspect: Megtekintés + invariants_hold: Minden főkönyvi invariáns teljesül. + invariants_violated: A főkönyvi invariánsok sérültek — kézi javítás előtt vizsgálja ki az okot. + ledger: Főkönyv + lifetime_earned: Összesen szerzett + lot: Pontcsomag + lots: Pontcsomagok (visszajátszásból származtatva) + loyalty: Hűségprogram + manual_adjustment: Kézi módosítás + manual_reason: + correction: Javítás + goodwill: Méltányosság + other: Egyéb + promotion: Promóció + never: soha + note: Megjegyzés + points: Pontok + points_amount: '%points% pont' + points_applied: '%points% pont felhasználva' + points_redemption: Pontbeváltás + program: Program + program_card_description: Csatornánkénti átváltás, lejárat és szabályzatok + reason: Indok + refer_a_friend: Ajánljon egy barátjának + referee: Ajánlott + referral_explainer: Ossza meg a linkjét — amikor egy barátja leadja első rendelését, mindketten pontokat kapnak. + referral_override: Jóváhagyás mindenképp + referral_stats: 'Önnek %count% jutalmazott ajánlása van, amelyek %points% pontot hoztak.' + referral_status: + expired: Lejárt + pending: Függőben + qualified: Megfelelt + rejected: Elutasítva + rewarded: Jutalmazva + referrals: Ajánlások + referrals_card_description: Ajánlási állapotok és csalásellenőrzési felülbírálások + referrer: Ajánló + redemption_clamped: 'Az Ön %requested% pontos kérése csak részben kerül felhasználásra ezen a rendelésen; automatikusan visszanő, amint a rendelés lehetővé teszi.' + remaining: Fennmaradó + remove_redemption: Eltávolítás + rule: Szabály + rule_tester: Szabálytesztelő + rule_tester_card_description: Szabályok kiértékelése egy friss rendelésen, akár más dátummal + scope: Hatókör + share: Megosztás + copied: Másolva! + stat_accounts: Fiókok + stat_active_accounts_90_days: Aktív fiókok (90 nap) + stat_liability: Fennálló pontkötelezettség + stat_redemption_rate_90_days: Beváltási arány (90 nap) + stat_earned_30_days: Szerzett pontok (30 nap) + stat_redeemed_30_days: Beváltott pontok (30 nap) + tester_applied: Alkalmazva + tester_claimed_basis: Lefoglalt alap + tester_claimed_by: Lefoglalta + tester_evaluate: Kiértékelés + tester_evaluate_at: Kiértékelés úgy, mintha ekkor lenne + tester_evaluate_at_help: Ütemezett szabályok előnézete az időszakuk megnyílása előtt + tester_failed_conditions: Nem teljesült feltételek + tester_final_award: 'Végső jóváírás: %points% pont' + tester_invalid_date: A kiértékelési dátum érvénytelen. + tester_item_claims: Tételenkénti foglalások + tester_matched: Egyezett + tester_order_has_no_customer: A rendeléshez nem tartozik vásárló, ezért egyetlen szabály sem értékelhető ki. + tester_order_not_found: Nem található rendelés ezzel a számmal. + tester_order_number: Rendelésszám + tester_rules: Szabálykiértékelések + tier: Szint + tier_progress: '%metric% / %threshold% a(z) %tier% szintig' + tier_top: Elérte a legfelső szintet — köszönjük, hogy legjobb vásárlóink egyike! + tiers: Szintek + tiers_card_description: Szintek, küszöbértékek, szorzók és előnyök + trigger: Eseményindító + use_max: Maximum használata + your_code: Az Ön kódja + use_your_points: Használja fel pontjait diff --git a/src/Resources/translations/messages.it.yaml b/src/Resources/translations/messages.it.yaml new file mode 100644 index 0000000..12fe07e --- /dev/null +++ b/src/Resources/translations/messages.it.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Aggiungi condizione + amount_configuration: 'Configurazione dell''importo' + amount_configuration_help: 'JSON, ad es. {"points": 1, "per_amount": 100} per importo proporzionale, {"points": 50} per fisso, {"factor": 2} per moltiplicatore, {"expression": "floor(basis / 100)"} per espressione' + amount_expression: 'Espressione dell''importo' + amount_expression_help: Usata quando il tipo di importo è «Espressione»; la variabile «basis» contiene la base rivendicata dalla regola in unità minori + amount_requires_order_trigger: 'I tipi per importo e moltiplicatore richiedono una base d''ordine e sono disponibili solo per il trigger d''ordine integrato.' + amount_type: Tipo di importo + condition_configuration: Configurazione della condizione + condition_configuration_help: 'JSON, ad es. {"amount": 5000} o {"taxons": ["t_shirts"]}' + condition_expression: Espressione della condizione + condition_expression_help: 'Usata quando il tipo di condizione è «Espressione»; la condizione è soddisfatta quando l''espressione è vera' + condition_type: Tipo di condizione + expression_required: 'La modalità espressione richiede un''espressione.' + multiplier_is_order_scoped: 'Le regole moltiplicatore si applicano solo all''intero ordine; usa la modalità espressione per la logica a livello di articolo.' + scope_requires_order_trigger: 'Gli ambiti taxon e prodotto richiedono articoli d''ordine e sono disponibili solo per il trigger d''ordine integrato.' + conditions: Condizioni + conditions_match: Corrispondenza delle condizioni + conditions_match_all: Tutte le condizioni devono essere soddisfatte + conditions_match_any: Almeno una condizione deve essere soddisfatta + dry_run: Simulazione + dry_run_help: 'Valuta sul traffico reale e registra ciò che sarebbe stato assegnato nell''elenco di audit, senza accreditare alcun punto' + ends_at: Termina il + scope: Ambito + scope_configuration: 'Configurazione dell''ambito' + scope_configuration_help: 'JSON, ad es. {"taxons": ["t_shirts"]} o {"products": ["MUG"]}; lasciare vuoto per l''ambito ordine' + scope_help: Le regole limitate a prodotto o taxon rivendicano in esclusiva gli articoli corrispondenti; le regole a livello di ordine guadagnano sul resto + scope_order: Intero ordine + scope_product: Prodotti specifici + scope_taxon: Taxon specifici + stackable: Cumulabile + stackable_help: Le regole cumulabili in concorrenza per la stessa base si applicano tutte e si sommano; una regola non cumulabile si applica da sola (vince la priorità più alta) + starts_at: Inizia il + trigger: Trigger + window_timezone_help: 'Inserito e valutato nel fuso orario configurato dell''applicazione' + earning_rule.amount: + expression: Espressione + fixed: Punti fissi + multiplier: Moltiplicatore + per_amount: Punti per importo + earning_rule.condition: + cart_contains_taxon: Il carrello contiene un taxon + customer_group: Gruppo di clienti + date_window: Intervallo di date + day_of_week: Giorno della settimana + expression: Espressione + nth_order: Ennesimo ordine + order_total_at_least: Totale ordine di almeno + invalid_json: Questo valore non è un JSON valido. + program: + award_moment_order_fulfilled: 'Quando l''ordine è evaso' + award_moment_payment_paid: Quando il pagamento è interamente saldato + award_order_points_at: 'Assegna i punti dell''ordine al momento' + award_order_points_at_help: 'L''unico momento del ciclo di vita dell''ordine in cui i punti vengono assegnati — uno per programma, mai per regola' + base_currency_help: Gli importi sono in unità minori della valuta di base del canale + clawback_policy: Politica di recupero + clawback_policy_allow_negative: Consenti saldo negativo (recuperato dai guadagni futuri) + clawback_policy_clamp_to_zero: Limita il saldo a zero + earning_basis: Base di accumulo + earning_basis_help: 'Ciò che conta ai fini dell''accumulo; il tasso di accumulo è definito dalle regole di accumulo' + earning_basis_items_total: Totale articoli (spedizione esclusa) + earning_basis_order_total: Totale ordine (spedizione inclusa) + include_taxes: Includi le imposte nella base + max_redeem_percent_of_order: 'Percentuale max. riscattabile dell''ordine' + max_redeem_percent_of_order_help: 'La quota del totale articoli dell''ordine copribile con i punti (100 consente ordini interamente gratuiti)' + min_redeem_points: Punti minimi per riscatto + points_expiry_days: I punti scadono dopo (giorni) + points_expiry_days_help: Lasciare vuoto perché i punti non scadano mai. Nota che la scadenza del valore accumulato è soggetta a vincoli di legge sui consumatori in alcune giurisdizioni + redemption_conversion_amount: '... valgono questo importo (unità minori)' + redemption_conversion_help: 'La conversione di riscatto: questo numero di punti...' + redemption_conversion_points: Unità di punti + retroactive_guest_points: Punti ospite retroattivi + retroactive_guest_points_help: 'Assegna punti per gli ordini effettuati come ospite prima della registrazione quando l''ospite si registra (nessun recupero per registrazioni precedenti)' + rounding: Arrotondamento + show_earnable_in_cart: Mostra il suggerimento sui punti nel carrello + show_earnable_on_product: Mostra il suggerimento sui punti nelle pagine prodotto + tier_downgrade_grace_days: Tolleranza prima della retrocessione di livello (giorni) + tier_downgrade_grace_days_help: Per quanto tempo un account mantiene il proprio livello dopo essere sceso sotto la soglia; 0 retrocede alla successiva valutazione notturna + tier_evaluation_window: Finestra di valutazione dei livelli + tier_window_calendar_year: Anno solare + tier_window_lifetime: A vita + tier_window_rolling_12_months: 12 mesi mobili + rounding_ceil: Per eccesso + rounding_floor: Per difetto + rounding_round: Al più vicino + promotion_rule: + customer_loyalty_tier: Il livello fedeltà del cliente è almeno + minimum_tier: Livello minimo + tier: + benefits: Vantaggi + benefits_description: Descrizione dei vantaggi + color: Colore del badge + earning_multiplier: Moltiplicatore di accumulo + earning_multiplier_help: 'Applicato a ogni accumulo di punti finché l''account è in questo livello (1 = nessuna modifica)' + position_help: Le posizioni più alte sono livelli più alti; la posizione più alta è il livello massimo + qualification_basis: Base di qualificazione + threshold: Soglia + threshold_help: 'L''unità dipende dalla base (punti, unità minori di valuta o ordini)' + tier_basis: + amount_spent: Importo speso + orders_count: Ordini effettuati + points_earned: Punti guadagnati + unit: + currency: unità minori di valuta + orders: ordini + points: punti + trigger: + customer_birthday: Compleanno del cliente + customer_registered: Cliente registrato + order_eligible: Ordine (integrato) + product_review_approved: Recensione prodotto approvata + ui: + account: Account fedeltà + account_inactive: 'Il tuo account fedeltà è attualmente inattivo — il tuo saldo è conservato, ma l''accumulo e il riscatto sono sospesi.' + accounts: Account fedeltà + accounts_card_description: Sfoglia gli account, ispeziona i registri, regola i punti + adjust: Regola + anonymized: Anonimizzato + balance: Il tuo saldo punti + balance_label: Saldo + change_on_cart: Modifica nel carrello + consumptions: Consumato da + dashboard_subtitle: Punti, regole di accumulo e impostazioni del programma + derived_balance: Somma del registro + dry_run: Simulazione + dry_run_results: Risultati della simulazione + dry_run_results_card_description: Ciò che le regole in simulazione avrebbero assegnato + earn_hint_cart: 'Questo ordine fa guadagnare ~%points% punti.' + earn_hint_product: 'Guadagnerai %points% punti acquistando questo prodotto.' + earning_rate_hint: 'Il tasso di accumulo è definito dalle regole di accumulo: una regola base a livello di ordine con il tipo «punti per importo» è il tasso predefinito del negozio.' + earning_rules: Regole di accumulo + earning_rules_card_description: Trigger, condizioni e importi + edit_earning_rule: Modifica regola di accumulo + edit_program: Modifica programma + new_earning_rule: Nuova regola di accumulo + description: Descrizione + expired: Scaduto + expires: 'scade il %date%' + expires_at: Scade il + expiring_soon: '%points% punti scadono prima del %date%.' + history: Cronologia delle transazioni + history_description: + clawback: 'Punti recuperati per un ordine annullato o rimborsato' + earn_action: 'Punti guadagnati' + earn_order: 'Guadagnati sull''ordine %order%' + earn_referral: 'Premio per referral' + expire: 'Punti scaduti' + manual_credit: 'Rettifica: %reason%' + manual_debit: 'Rettifica: %reason%' + redeem: 'Riscattati sull''ordine %order%' + redeem_rollback: 'Punti ripristinati da un ordine annullato' + history_empty: Nessuna transazione al momento — i punti che guadagni e spendi appariranno qui. + my_loyalty: La mia fedeltà + running_balance: Saldo + inspect: Ispeziona + invariants_hold: Tutti gli invarianti del registro sono rispettati. + invariants_violated: Gli invarianti del registro sono violati — indaga prima di effettuare correzioni manuali. + ledger: Registro + lifetime_earned: Guadagnati in totale + lot: Lotto + lots: Lotti (derivati dal replay) + loyalty: Fedeltà + manual_adjustment: Rettifica manuale + manual_reason: + correction: Correzione + goodwill: Gesto di cortesia + other: Altro + promotion: Promozione + never: mai + note: Nota + points: Punti + points_amount: '%points% punti' + points_applied: '%points% punti applicati' + points_redemption: Riscatto punti + program: Programma + program_card_description: Conversione, scadenza e politiche per canale + reason: Motivo + refer_a_friend: Presenta un amico + referee: Invitato + referral_explainer: Condividi il tuo link — quando un amico effettua il suo primo ordine, guadagnate punti entrambi. + referral_override: Approva comunque + referral_stats: 'Hai %count% referral premiati, che hanno fruttato %points% punti.' + referral_status: + expired: Scaduto + pending: In attesa + qualified: Qualificato + rejected: Rifiutato + rewarded: Premiato + referrals: Referral + referrals_card_description: Stati dei referral e forzature antifrode + referrer: Presentatore + redemption_clamped: 'La tua richiesta di %requested% punti è applicata solo parzialmente a questo ordine; aumenterà di nuovo automaticamente quando l''ordine lo consentirà.' + remaining: Rimanenti + remove_redemption: Rimuovi + rule: Regola + rule_tester: Tester delle regole + rule_tester_card_description: Valuta le regole su un ordine recente, facoltativamente a una data diversa + scope: Ambito + share: Condividi + copied: Copiato! + stat_accounts: Account + stat_active_accounts_90_days: Account attivi (90 giorni) + stat_liability: Passività punti in essere + stat_redemption_rate_90_days: Tasso di riscatto (90 giorni) + stat_earned_30_days: Punti guadagnati (30 giorni) + stat_redeemed_30_days: Punti riscattati (30 giorni) + tester_applied: Applicato + tester_claimed_basis: Base rivendicata + tester_claimed_by: Rivendicato da + tester_evaluate: Valuta + tester_evaluate_at: Valuta come se fosse + tester_evaluate_at_help: 'Anteprima delle regole pianificate prima dell''apertura del loro periodo' + tester_failed_conditions: Condizioni non soddisfatte + tester_final_award: 'Assegnazione finale: %points% punti' + tester_invalid_date: La data di valutazione non è valida. + tester_item_claims: Rivendicazioni per articolo + tester_matched: Corrisponde + tester_order_has_no_customer: 'L''ordine non ha un cliente, quindi nessuna regola può essere valutata.' + tester_order_not_found: Nessun ordine trovato con quel numero. + tester_order_number: 'Numero d''ordine' + tester_rules: Valutazioni delle regole + tier: Livello + tier_progress: '%metric% / %threshold% per %tier%' + tier_top: Hai raggiunto il livello più alto — grazie per essere uno dei nostri migliori clienti! + tiers: Livelli + tiers_card_description: Livelli, soglie, moltiplicatori e vantaggi + trigger: Trigger + use_max: Usa il massimo + your_code: Il tuo codice + use_your_points: Usa i tuoi punti diff --git a/src/Resources/translations/messages.nl.yaml b/src/Resources/translations/messages.nl.yaml new file mode 100644 index 0000000..deb0e1a --- /dev/null +++ b/src/Resources/translations/messages.nl.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Voorwaarde toevoegen + amount_configuration: Bedragconfiguratie + amount_configuration_help: 'JSON, bijv. {"points": 1, "per_amount": 100} voor per bedrag, {"points": 50} voor vast, {"factor": 2} voor vermenigvuldiger, {"expression": "floor(basis / 100)"} voor expressie' + amount_expression: Bedragexpressie + amount_expression_help: Wordt gebruikt wanneer het bedragtype "Expressie" is; de variabele "basis" bevat de door de regel geclaimde basis in de kleinste munteenheid + amount_requires_order_trigger: De typen per bedrag en vermenigvuldiger hebben een bestelbasis nodig en zijn alleen beschikbaar voor de ingebouwde besteltrigger. + amount_type: Bedragtype + condition_configuration: Voorwaardeconfiguratie + condition_configuration_help: 'JSON, bijv. {"amount": 5000} of {"taxons": ["t_shirts"]}' + condition_expression: Voorwaarde-expressie + condition_expression_help: Wordt gebruikt wanneer het voorwaardetype "Expressie" is; de voorwaarde slaagt wanneer de expressie waar is + condition_type: Voorwaardetype + expression_required: De expressiemodus vereist een expressie. + multiplier_is_order_scoped: Vermenigvuldigingsregels gelden alleen op bestelniveau; gebruik de expressiemodus voor logica op artikelniveau. + scope_requires_order_trigger: De taxon- en productbereiken hebben bestelartikelen nodig en zijn alleen beschikbaar voor de ingebouwde besteltrigger. + conditions: Voorwaarden + conditions_match: Voorwaarden matchen + conditions_match_all: Aan alle voorwaarden moet worden voldaan + conditions_match_any: Aan ten minste één voorwaarde moet worden voldaan + dry_run: Proefdraaien + dry_run_help: Evalueer op live verkeer en log wat zou zijn toegekend in de auditlijst, zonder punten weg te schrijven + ends_at: Eindigt op + scope: Bereik + scope_configuration: Bereikconfiguratie + scope_configuration_help: 'JSON, bijv. {"taxons": ["t_shirts"]} of {"products": ["MUG"]}; leeg laten voor bestelbereik' + scope_help: Product- of taxongebonden regels claimen hun overeenkomende artikelen exclusief; regels op bestelniveau sparen over de rest + scope_order: Hele bestelling + scope_product: Specifieke producten + scope_taxon: Specifieke taxons + stackable: Stapelbaar + stackable_help: Stapelbare regels die om dezelfde basis strijden, gelden allemaal en worden opgeteld; een niet-stapelbare regel geldt alleen (hoogste prioriteit wint) + starts_at: Begint op + trigger: Trigger + window_timezone_help: Ingevoerd en geëvalueerd in de geconfigureerde tijdzone van de applicatie + earning_rule.amount: + expression: Expressie + fixed: Vaste punten + multiplier: Vermenigvuldiger + per_amount: Punten per bedrag + earning_rule.condition: + cart_contains_taxon: Winkelwagen bevat taxon + customer_group: Klantengroep + date_window: Datumperiode + day_of_week: Dag van de week + expression: Expressie + nth_order: N-de bestelling + order_total_at_least: Besteltotaal minimaal + invalid_json: Deze waarde is geen geldige JSON. + program: + award_moment_order_fulfilled: Wanneer de bestelling is afgehandeld + award_moment_payment_paid: Wanneer de betaling volledig is voldaan + award_order_points_at: Bestelpunten toekennen bij + award_order_points_at_help: Het enige moment in de levenscyclus van de bestelling waarop punten worden toegekend — één per programma, nooit per regel + base_currency_help: Bedragen zijn in de kleinste munteenheid van de basisvaluta van het kanaal + clawback_policy: Terugvorderingsbeleid + clawback_policy_allow_negative: Negatief saldo toestaan (verrekend met toekomstige punten) + clawback_policy_clamp_to_zero: Saldo begrenzen op nul + earning_basis: Spaarbasis + earning_basis_help: Wat meetelt voor het sparen; het spaartarief zelf wordt bepaald door spaarregels + earning_basis_items_total: Artikeltotaal (excl. verzending) + earning_basis_order_total: Besteltotaal (incl. verzending) + include_taxes: Belastingen meenemen in de basis + max_redeem_percent_of_order: Max. inwisselbaar percentage van de bestelling + max_redeem_percent_of_order_help: Het deel van het artikeltotaal van de bestelling dat met punten gedekt kan worden (100 staat volledig gratis bestellingen toe) + min_redeem_points: Minimaal aantal punten per inwisseling + points_expiry_days: Punten verlopen na (dagen) + points_expiry_days_help: Laat leeg zodat punten nooit verlopen. Let op dat het laten verlopen van opgebouwd tegoed in sommige rechtsgebieden onderworpen is aan consumentenwetgeving + redemption_conversion_amount: '... zijn dit bedrag waard (kleinste munteenheid)' + redemption_conversion_help: 'De inwisselconversie: dit aantal punten...' + redemption_conversion_points: Punteneenheid + retroactive_guest_points: Gastpunten met terugwerkende kracht + retroactive_guest_points_help: Ken punten toe voor gastbestellingen van vóór de registratie wanneer de gast zich registreert (geen toekenning achteraf voor eerdere registraties) + rounding: Afronding + show_earnable_in_cart: Toon de spaarhint in de winkelwagen + show_earnable_on_product: 'Toon de spaarhint op productpagina''s' + tier_downgrade_grace_days: Respijt bij niveauverlaging (dagen) + tier_downgrade_grace_days_help: Hoe lang een account zijn niveau behoudt na onder de drempel te zijn gezakt; 0 verlaagt bij de eerstvolgende nachtelijke evaluatie + tier_evaluation_window: Evaluatievenster voor niveaus + tier_window_calendar_year: Kalenderjaar + tier_window_lifetime: Levenslang + tier_window_rolling_12_months: Voortschrijdende 12 maanden + rounding_ceil: Naar boven + rounding_floor: Naar beneden + rounding_round: Afronden + promotion_rule: + customer_loyalty_tier: Loyaliteitsniveau van de klant is minimaal + minimum_tier: Minimumniveau + tier: + benefits: Voordelen + benefits_description: Beschrijving van de voordelen + color: Badgekleur + earning_multiplier: Spaarvermenigvuldiger + earning_multiplier_help: Toegepast op elke puntenbijschrijving zolang het account in dit niveau zit (1 = geen wijziging) + position_help: Hogere posities zijn hogere niveaus; de hoogste positie is het topniveau + qualification_basis: Kwalificatiegrondslag + threshold: Drempel + threshold_help: De eenheid hangt af van de grondslag (punten, kleinste munteenheden of bestellingen) + tier_basis: + amount_spent: Besteed bedrag + orders_count: Geplaatste bestellingen + points_earned: Gespaarde punten + unit: + currency: kleinste munteenheden + orders: bestellingen + points: punten + trigger: + customer_birthday: Verjaardag van de klant + customer_registered: Klant geregistreerd + order_eligible: Bestelling (ingebouwd) + product_review_approved: Productreview goedgekeurd + ui: + account: Loyaliteitsaccount + account_inactive: Je loyaliteitsaccount is momenteel inactief — je saldo blijft behouden, maar sparen en inwisselen zijn gepauzeerd. + accounts: Loyaliteitsaccounts + accounts_card_description: Accounts doorbladeren, grootboeken inspecteren, punten aanpassen + adjust: Aanpassen + anonymized: Geanonimiseerd + balance: Je puntensaldo + balance_label: Saldo + change_on_cart: Wijzigen in de winkelwagen + consumptions: Verbruikt door + dashboard_subtitle: Punten, spaarregels en programma-instellingen + derived_balance: Grootboeksom + dry_run: Proefdraaien + dry_run_results: Proefdraairesultaten + dry_run_results_card_description: Wat proefdraairegels zouden hebben toegekend + earn_hint_cart: 'Met deze bestelling spaar je ~%points% punten.' + earn_hint_product: 'Je spaart %points% punten bij aankoop van dit product.' + earning_rate_hint: 'Het spaartarief wordt bepaald door spaarregels: een basisregel op bestelniveau met het type "punten per bedrag" is het standaardtarief van de winkel.' + earning_rules: Spaarregels + earning_rules_card_description: Triggers, voorwaarden en bedragen + edit_earning_rule: Spaarregel bewerken + edit_program: Programma bewerken + new_earning_rule: Nieuwe spaarregel + description: Beschrijving + expired: Verlopen + expires: 'verloopt op %date%' + expires_at: Verloopt op + expiring_soon: '%points% punten verlopen vóór %date%.' + history: Transactiegeschiedenis + history_description: + clawback: 'Punten teruggevorderd voor een geannuleerde of terugbetaalde bestelling' + earn_action: 'Punten gespaard' + earn_order: 'Gespaard op bestelling %order%' + earn_referral: 'Doorverwijzingsbeloning' + expire: 'Punten verlopen' + manual_credit: 'Correctie: %reason%' + manual_debit: 'Correctie: %reason%' + redeem: 'Ingewisseld op bestelling %order%' + redeem_rollback: 'Punten teruggezet van een geannuleerde bestelling' + history_empty: Nog geen transacties — punten die je spaart en uitgeeft, verschijnen hier. + my_loyalty: Mijn loyaliteit + running_balance: Saldo + inspect: Inspecteren + invariants_hold: Alle grootboekinvarianten kloppen. + invariants_violated: De grootboekinvarianten zijn geschonden — onderzoek dit voordat je handmatige correcties doorvoert. + ledger: Grootboek + lifetime_earned: Totaal gespaard + lot: Partij + lots: Partijen (afgeleid via replay) + loyalty: Loyaliteit + manual_adjustment: Handmatige correctie + manual_reason: + correction: Correctie + goodwill: Coulance + other: Overig + promotion: Promotie + never: nooit + note: Notitie + points: Punten + points_amount: '%points% punten' + points_applied: '%points% punten toegepast' + points_redemption: Punten inwisselen + program: Programma + program_card_description: Conversie, vervaldatum en beleid per kanaal + reason: Reden + refer_a_friend: Verwijs een vriend door + referee: Doorverwezen persoon + referral_explainer: Deel je link — wanneer een vriend zijn eerste bestelling plaatst, sparen jullie allebei punten. + referral_override: Toch goedkeuren + referral_stats: 'Je hebt %count% beloonde doorverwijzing(en), goed voor %points% punten.' + referral_status: + expired: Verlopen + pending: In afwachting + qualified: Gekwalificeerd + rejected: Afgewezen + rewarded: Beloond + referrals: Doorverwijzingen + referrals_card_description: Doorverwijzingsstatussen en het overrulen van fraudecontroles + referrer: Doorverwijzer + redemption_clamped: 'Je verzoek van %requested% punten wordt slechts gedeeltelijk op deze bestelling toegepast; het groeit automatisch weer aan zodra de bestelling dat toelaat.' + remaining: Resterend + remove_redemption: Verwijderen + rule: Regel + rule_tester: Regeltester + rule_tester_card_description: Regels evalueren op een recente bestelling, eventueel op een andere datum + scope: Bereik + share: Delen + copied: Gekopieerd! + stat_accounts: Accounts + stat_active_accounts_90_days: Actieve accounts (90 dagen) + stat_liability: Uitstaande puntenverplichting + stat_redemption_rate_90_days: Inwisselpercentage (90 dagen) + stat_earned_30_days: Punten gespaard (30 dagen) + stat_redeemed_30_days: Punten ingewisseld (30 dagen) + tester_applied: Toegepast + tester_claimed_basis: Geclaimde basis + tester_claimed_by: Geclaimd door + tester_evaluate: Evalueren + tester_evaluate_at: Evalueren alsof het is + tester_evaluate_at_help: Bekijk geplande regels voordat hun periode opent + tester_failed_conditions: Niet-vervulde voorwaarden + tester_final_award: 'Uiteindelijke toekenning: %points% punten' + tester_invalid_date: De evaluatiedatum is ongeldig. + tester_item_claims: Claims per artikel + tester_matched: Match + tester_order_has_no_customer: De bestelling heeft geen klant, dus er kunnen geen regels worden geëvalueerd. + tester_order_not_found: Er is geen bestelling met dat nummer gevonden. + tester_order_number: Bestelnummer + tester_rules: Regelevaluaties + tier: Niveau + tier_progress: '%metric% / %threshold% tot %tier%' + tier_top: Je hebt het hoogste niveau bereikt — bedankt dat je een van onze allerbeste klanten bent! + tiers: Niveaus + tiers_card_description: Niveaus, drempels, vermenigvuldigers en voordelen + trigger: Trigger + use_max: Maximum gebruiken + your_code: Jouw code + use_your_points: Gebruik je punten diff --git a/src/Resources/translations/messages.no.yaml b/src/Resources/translations/messages.no.yaml new file mode 100644 index 0000000..6ba4878 --- /dev/null +++ b/src/Resources/translations/messages.no.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Legg til betingelse + amount_configuration: Beløpskonfigurasjon + amount_configuration_help: 'JSON, f.eks. {"points": 1, "per_amount": 100} for per beløp, {"points": 50} for fast, {"factor": 2} for multiplikator, {"expression": "floor(basis / 100)"} for uttrykk' + amount_expression: Beløpsuttrykk + amount_expression_help: Brukes når beløpstypen er "Uttrykk"; variabelen "basis" inneholder grunnlaget regelen gjør krav på, i minste enheter + amount_requires_order_trigger: Typene per beløp og multiplikator krever et ordregrunnlag og er kun tilgjengelige for den innebygde ordre-triggeren. + amount_type: Beløpstype + condition_configuration: Betingelseskonfigurasjon + condition_configuration_help: 'JSON, f.eks. {"amount": 5000} eller {"taxons": ["t_shirts"]}' + condition_expression: Betingelsesuttrykk + condition_expression_help: Brukes når betingelsestypen er "Uttrykk"; betingelsen er oppfylt når uttrykket evalueres som sant + condition_type: Betingelsestype + expression_required: Uttrykksmodus krever et uttrykk. + multiplier_is_order_scoped: Multiplikatorregler gjelder kun hele ordren; bruk uttrykksmodus for logikk på varelinjenivå. + scope_requires_order_trigger: Taxon- og produktavgrensninger krever ordrelinjer og er kun tilgjengelige for den innebygde ordre-triggeren. + conditions: Betingelser + conditions_match: Match av betingelser + conditions_match_all: Alle betingelser må være oppfylt + conditions_match_any: Minst én betingelse må være oppfylt + dry_run: Testkjøring + dry_run_help: Evaluer mot reell trafikk og logg til revisjonslisten hva som ville blitt tildelt, uten å skrive noen point + ends_at: Slutter + scope: Avgrensning + scope_configuration: Avgrensningskonfigurasjon + scope_configuration_help: 'JSON, f.eks. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; la feltet stå tomt for hele ordren' + scope_help: Produkt- eller taxonavgrensede regler gjør eksklusivt krav på sine matchende varer; ordreavgrensede regler opptjener på resten + scope_order: Hele ordren + scope_product: Bestemte produkter + scope_taxon: Bestemte taxoner + stackable: Kan kombineres + stackable_help: Regler som kan kombineres og konkurrerer om samme grunnlag, gjelder alle og summeres; en regel som ikke kan kombineres, gjelder alene (høyeste prioritet vinner) + starts_at: Starter + trigger: Trigger + window_timezone_help: Angis og evalueres i applikasjonens konfigurerte tidssone + earning_rule.amount: + expression: Uttrykk + fixed: Faste point + multiplier: Multiplikator + per_amount: Point per beløp + earning_rule.condition: + cart_contains_taxon: Handlekurven inneholder taxon + customer_group: Kundegruppe + date_window: Datointervall + day_of_week: Ukedag + expression: Uttrykk + nth_order: N-te ordre + order_total_at_least: Ordretotal minst + invalid_json: Denne verdien er ikke gyldig JSON. + program: + award_moment_order_fulfilled: Når ordren er fullført + award_moment_payment_paid: Når betalingen er fullt betalt + award_order_points_at: Tidspunkt for tildeling av ordrepoint + award_order_points_at_help: Det ene tidspunktet i ordreforløpet der point tildeles — ett per program, aldri per regel + base_currency_help: Beløp angis i minste enheter av kanalens basisvaluta + clawback_policy: Tilbakeføringspolicy + clawback_policy_allow_negative: Tillat negativ saldo (inndrives fra fremtidig opptjening) + clawback_policy_clamp_to_zero: Begrens saldoen til null + earning_basis: Opptjeningsgrunnlag + earning_basis_help: Hva som teller med i opptjeningen; selve opptjeningssatsen defineres av opptjeningsregler + earning_basis_items_total: Varetotal (ekskl. frakt) + earning_basis_order_total: Ordretotal (inkl. frakt) + include_taxes: Inkluder mva. i grunnlaget + max_redeem_percent_of_order: Maks. innløsbar prosent av ordren + max_redeem_percent_of_order_help: Andelen av ordrens varetotal som kan dekkes av point (100 tillater helt gratis ordrer) + min_redeem_points: Minimum point per innløsning + points_expiry_days: Point utløper etter (dager) + points_expiry_days_help: La feltet stå tomt slik at point aldri utløper. Merk at utløp av oppspart verdi kan være underlagt forbrukerrettslige begrensninger i enkelte jurisdiksjoner + redemption_conversion_amount: '... er verdt dette beløpet (minste enheter)' + redemption_conversion_help: 'Innløsningskursen: så mange point...' + redemption_conversion_points: Pointenhet + retroactive_guest_points: Point for tidligere gjesteordrer + retroactive_guest_points_help: Tildel point for gjesteordrer lagt inn før registrering når gjesten registrerer seg (ingen etterbetaling for tidligere registreringer) + rounding: Avrunding + show_earnable_in_cart: Vis opptjeningshintet i handlekurven + show_earnable_on_product: Vis opptjeningshintet på produktsider + tier_downgrade_grace_days: Frist ved nivånedrykk (dager) + tier_downgrade_grace_days_help: Hvor lenge en konto beholder nivået sitt etter å ha falt under terskelen; 0 nedgraderer ved neste nattlige evaluering + tier_evaluation_window: Evalueringsvindu for nivåer + tier_window_calendar_year: Kalenderår + tier_window_lifetime: Levetid + tier_window_rolling_12_months: Rullerende 12 måneder + rounding_ceil: Rund opp + rounding_floor: Rund ned + rounding_round: Avrund + promotion_rule: + customer_loyalty_tier: Kundens lojalitetsnivå er minst + minimum_tier: Minimumsnivå + tier: + benefits: Fordeler + benefits_description: Beskrivelse av fordelene + color: Badgefarge + earning_multiplier: Opptjeningsmultiplikator + earning_multiplier_help: Brukes på all pointopptjening mens kontoen er på dette nivået (1 = ingen endring) + position_help: Høyere posisjoner er høyere nivåer; den høyeste posisjonen er toppnivået + qualification_basis: Kvalifiseringsgrunnlag + threshold: Terskel + threshold_help: Enheten avhenger av grunnlaget (point, minste valutaenheter eller ordrer) + tier_basis: + amount_spent: Beløp brukt + orders_count: Ordrer lagt inn + points_earned: Opptjente point + unit: + currency: minste valutaenheter + orders: ordrer + points: point + trigger: + customer_birthday: Kundens fødselsdag + customer_registered: Kunde registrert + order_eligible: Ordre (innebygd) + product_review_approved: Produktanmeldelse godkjent + ui: + account: Lojalitetskonto + account_inactive: Lojalitetskontoen din er for øyeblikket inaktiv — saldoen din er bevart, men opptjening og innløsning er satt på pause. + accounts: Lojalitetskontoer + accounts_card_description: Bla gjennom kontoer, inspiser hovedbøker, juster point + adjust: Juster + anonymized: Anonymisert + balance: Din pointsaldo + balance_label: Saldo + change_on_cart: Endre i handlekurven + consumptions: Forbrukt av + dashboard_subtitle: Point, opptjeningsregler og programinnstillinger + derived_balance: Sum i hovedboken + dry_run: Testkjøring + dry_run_results: Testkjøringsresultater + dry_run_results_card_description: Hva testkjøringsregler ville ha tildelt + earn_hint_cart: 'Denne ordren gir ~%points% point.' + earn_hint_product: 'Du tjener %points% point ved kjøp av dette produktet.' + earning_rate_hint: 'Opptjeningssatsen defineres av opptjeningsregler: en basisregel for hele ordren med typen per beløp er butikkens standardsats.' + earning_rules: Opptjeningsregler + earning_rules_card_description: Triggere, betingelser og beløp + edit_earning_rule: Rediger opptjeningsregel + edit_program: Rediger program + new_earning_rule: Ny opptjeningsregel + description: Beskrivelse + expired: Utløpt + expires: 'utløper %date%' + expires_at: Utløper + expiring_soon: '%points% point utløper før %date%.' + history: Transaksjonshistorikk + history_description: + clawback: 'Point tilbakeført for en kansellert eller refundert ordre' + earn_action: 'Point opptjent' + earn_order: 'Opptjent på ordre %order%' + earn_referral: 'Vervebelønning' + expire: 'Point utløpt' + manual_credit: 'Justering: %reason%' + manual_debit: 'Justering: %reason%' + redeem: 'Innløst på ordre %order%' + redeem_rollback: 'Point gjenopprettet fra en kansellert ordre' + history_empty: Ingen transaksjoner ennå — point du opptjener og bruker, vises her. + my_loyalty: Mitt lojalitetsprogram + running_balance: Saldo + inspect: Inspiser + invariants_hold: Alle hovedbokens invarianter holder. + invariants_violated: Hovedbokens invarianter er brutt — undersøk saken før du gjør manuelle korrigeringer. + ledger: Hovedbok + lifetime_earned: Opptjent totalt + lot: Lot + lots: Lots (avledet ved reavspilling) + loyalty: Lojalitet + manual_adjustment: Manuell justering + manual_reason: + correction: Korrigering + goodwill: Goodwill + other: Annet + promotion: Kampanje + never: aldri + note: Notat + points: Point + points_amount: '%points% point' + points_applied: '%points% point brukt' + points_redemption: Pointinnløsning + program: Program + program_card_description: Konvertering, utløp og policyer per kanal + reason: Årsak + refer_a_friend: Verv en venn + referee: Vervet + referral_explainer: Del lenken din — når en venn legger inn sin første ordre, tjener dere begge point. + referral_override: Godkjenn likevel + referral_stats: 'Du har %count% belønnede verving(er) som har gitt %points% point.' + referral_status: + expired: Utløpt + pending: Venter + qualified: Kvalifisert + rejected: Avvist + rewarded: Belønnet + referrals: Vervinger + referrals_card_description: Vervingsstatuser og overstyring av svindelsjekker + referrer: Verver + redemption_clamped: 'Forespørselen din om %requested% point er bare delvis brukt på denne ordren; den økes automatisk igjen når ordren tillater det.' + remaining: Gjenstående + remove_redemption: Fjern + rule: Regel + rule_tester: Regeltester + rule_tester_card_description: Evaluer regler mot en nylig ordre, eventuelt på en annen dato + scope: Avgrensning + share: Del + copied: Kopiert! + stat_accounts: Kontoer + stat_active_accounts_90_days: Aktive kontoer (90 dager) + stat_liability: Utestående pointforpliktelse + stat_redemption_rate_90_days: Innløsningsgrad (90 dager) + stat_earned_30_days: Point opptjent (30 dager) + stat_redeemed_30_days: Point innløst (30 dager) + tester_applied: Brukt + tester_claimed_basis: Krevd grunnlag + tester_claimed_by: Krevd av + tester_evaluate: Evaluer + tester_evaluate_at: Evaluer som om det var + tester_evaluate_at_help: Forhåndsvis planlagte regler før tidsvinduet deres åpner + tester_failed_conditions: Ikke-oppfylte betingelser + tester_final_award: 'Endelig tildeling: %points% point' + tester_invalid_date: Evalueringsdatoen er ugyldig. + tester_item_claims: Krav per varelinje + tester_matched: Matchet + tester_order_has_no_customer: Ordren har ingen kunde, så ingen regler kan evalueres. + tester_order_not_found: Ingen ordre med det nummeret ble funnet. + tester_order_number: Ordrenummer + tester_rules: Regelevalueringer + tier: Nivå + tier_progress: '%metric% / %threshold% til %tier%' + tier_top: Du har nådd det høyeste nivået — takk for at du er en av våre aller beste kunder! + tiers: Nivåer + tiers_card_description: Nivåer, terskler, multiplikatorer og fordeler + trigger: Trigger + use_max: Bruk maks. + your_code: Din kode + use_your_points: Bruk pointene dine diff --git a/src/Resources/translations/messages.pl.yaml b/src/Resources/translations/messages.pl.yaml new file mode 100644 index 0000000..07c8708 --- /dev/null +++ b/src/Resources/translations/messages.pl.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Dodaj warunek + amount_configuration: Konfiguracja kwoty + amount_configuration_help: 'JSON, np. {"points": 1, "per_amount": 100} dla punktów za kwotę, {"points": 50} dla stałej liczby, {"factor": 2} dla mnożnika, {"expression": "floor(basis / 100)"} dla wyrażenia' + amount_expression: Wyrażenie kwoty + amount_expression_help: Używane, gdy typ kwoty to "Wyrażenie"; zmienna "basis" zawiera podstawę zajętą przez regułę w jednostkach mniejszych + amount_requires_order_trigger: Typy za kwotę i mnożnik wymagają podstawy zamówienia i są dostępne tylko dla wbudowanego wyzwalacza zamówienia. + amount_type: Typ kwoty + condition_configuration: Konfiguracja warunku + condition_configuration_help: 'JSON, np. {"amount": 5000} lub {"taxons": ["t_shirts"]}' + condition_expression: Wyrażenie warunku + condition_expression_help: Używane, gdy typ warunku to "Wyrażenie"; warunek jest spełniony, gdy wyrażenie jest prawdziwe + condition_type: Typ warunku + expression_required: Tryb wyrażenia wymaga wyrażenia. + multiplier_is_order_scoped: Reguły z mnożnikiem działają tylko na poziomie zamówienia; do logiki na poziomie pozycji użyj trybu wyrażenia. + scope_requires_order_trigger: Zakresy taksonu i produktu wymagają pozycji zamówienia i są dostępne tylko dla wbudowanego wyzwalacza zamówienia. + conditions: Warunki + conditions_match: Dopasowanie warunków + conditions_match_all: Wszystkie warunki muszą być spełnione + conditions_match_any: Co najmniej jeden warunek musi być spełniony + dry_run: Tryb testowy + dry_run_help: Oceniaj na rzeczywistym ruchu i zapisuj na liście audytu, co zostałoby przyznane, bez zapisywania żadnych punktów + ends_at: Kończy się + scope: Zakres + scope_configuration: Konfiguracja zakresu + scope_configuration_help: 'JSON, np. {"taxons": ["t_shirts"]} lub {"products": ["MUG"]}; pozostaw puste dla zakresu zamówienia' + scope_help: Reguły ograniczone do produktu lub taksonu zajmują na wyłączność pasujące pozycje; reguły na poziomie zamówienia naliczają punkty od reszty + scope_order: Całe zamówienie + scope_product: Wybrane produkty + scope_taxon: Wybrane taksony + stackable: Łączy się z innymi regułami + stackable_help: Reguły łączące się, które konkurują o tę samą podstawę, stosują się wszystkie i sumują; reguła niełącząca się stosuje się samodzielnie (wygrywa najwyższy priorytet) + starts_at: Zaczyna się + trigger: Wyzwalacz + window_timezone_help: Wprowadzane i oceniane w skonfigurowanej strefie czasowej aplikacji + earning_rule.amount: + expression: Wyrażenie + fixed: Stała liczba punktów + multiplier: Mnożnik + per_amount: Punkty za kwotę + earning_rule.condition: + cart_contains_taxon: Koszyk zawiera takson + customer_group: Grupa klientów + date_window: Przedział dat + day_of_week: Dzień tygodnia + expression: Wyrażenie + nth_order: N-te zamówienie + order_total_at_least: Suma zamówienia co najmniej + invalid_json: Ta wartość nie jest prawidłowym JSON-em. + program: + award_moment_order_fulfilled: Gdy zamówienie zostanie zrealizowane + award_moment_payment_paid: Gdy płatność zostanie w pełni opłacona + award_order_points_at: Przyznawaj punkty za zamówienie w momencie + award_order_points_at_help: Jedyny moment cyklu życia zamówienia, w którym przyznawane są punkty — jeden na program, nigdy na regułę + base_currency_help: Kwoty są w jednostkach mniejszych waluty bazowej kanału + clawback_policy: Polityka odbierania punktów + clawback_policy_allow_negative: Zezwalaj na ujemne saldo (odzyskiwane z przyszłych punktów) + clawback_policy_clamp_to_zero: Ogranicz saldo do zera + earning_basis: Podstawa naliczania + earning_basis_help: Co liczy się do naliczania punktów; samą stawkę naliczania określają reguły naliczania + earning_basis_items_total: Suma pozycji (bez wysyłki) + earning_basis_order_total: Suma zamówienia (z wysyłką) + include_taxes: Uwzględnij podatki w podstawie + max_redeem_percent_of_order: Maks. procent zamówienia do wymiany + max_redeem_percent_of_order_help: Część sumy pozycji zamówienia, którą można pokryć punktami (100 pozwala na całkowicie darmowe zamówienia) + min_redeem_points: Minimalna liczba punktów na wymianę + points_expiry_days: Punkty wygasają po (dniach) + points_expiry_days_help: Pozostaw puste, aby punkty nigdy nie wygasały. Pamiętaj, że wygasanie zgromadzonej wartości podlega w niektórych jurysdykcjach przepisom prawa konsumenckiego + redemption_conversion_amount: '... są warte tę kwotę (jednostki mniejsze)' + redemption_conversion_help: 'Przelicznik wymiany: tyle punktów...' + redemption_conversion_points: Jednostka punktów + retroactive_guest_points: Punkty wsteczne dla gości + retroactive_guest_points_help: Przyznawaj punkty za zamówienia złożone jako gość przed rejestracją, gdy gość się zarejestruje (bez naliczania wstecz dla wcześniejszych rejestracji) + rounding: Zaokrąglanie + show_earnable_in_cart: Pokazuj podpowiedź o punktach w koszyku + show_earnable_on_product: Pokazuj podpowiedź o punktach na stronach produktów + tier_downgrade_grace_days: Okres ochronny przed obniżeniem poziomu (dni) + tier_downgrade_grace_days_help: Jak długo konto zachowuje swój poziom po spadku poniżej progu; 0 obniża poziom przy najbliższej nocnej ocenie + tier_evaluation_window: Okno oceny poziomów + tier_window_calendar_year: Rok kalendarzowy + tier_window_lifetime: Cały okres + tier_window_rolling_12_months: Ostatnie 12 miesięcy (kroczące) + rounding_ceil: W górę + rounding_floor: W dół + rounding_round: Do najbliższej + promotion_rule: + customer_loyalty_tier: Poziom lojalnościowy klienta wynosi co najmniej + minimum_tier: Minimalny poziom + tier: + benefits: Korzyści + benefits_description: Opis korzyści + color: Kolor odznaki + earning_multiplier: Mnożnik naliczania + earning_multiplier_help: Stosowany do każdego naliczenia punktów, gdy konto jest na tym poziomie (1 = bez zmian) + position_help: Wyższe pozycje to wyższe poziomy; najwyższa pozycja to najwyższy poziom + qualification_basis: Podstawa kwalifikacji + threshold: Próg + threshold_help: Jednostka zależy od podstawy (punkty, jednostki mniejsze waluty lub zamówienia) + tier_basis: + amount_spent: Wydana kwota + orders_count: Złożone zamówienia + points_earned: Zdobyte punkty + unit: + currency: jednostki mniejsze waluty + orders: zamówienia + points: punkty + trigger: + customer_birthday: Urodziny klienta + customer_registered: Rejestracja klienta + order_eligible: Zamówienie (wbudowany) + product_review_approved: Zatwierdzona recenzja produktu + ui: + account: Konto lojalnościowe + account_inactive: Twoje konto lojalnościowe jest obecnie nieaktywne — saldo jest zachowane, ale zdobywanie i wymiana punktów są wstrzymane. + accounts: Konta lojalnościowe + accounts_card_description: Przeglądaj konta, sprawdzaj księgi, koryguj punkty + adjust: Koryguj + anonymized: Zanonimizowano + balance: Twoje saldo punktów + balance_label: Saldo + change_on_cart: Zmień w koszyku + consumptions: Zużyte przez + dashboard_subtitle: Punkty, reguły naliczania i ustawienia programu + derived_balance: Suma księgi + dry_run: Tryb testowy + dry_run_results: Wyniki trybu testowego + dry_run_results_card_description: Co przyznałyby reguły w trybie testowym + earn_hint_cart: 'To zamówienie da ~%points% punktów.' + earn_hint_product: 'Kupując ten produkt, zdobędziesz %points% punktów.' + earning_rate_hint: 'Stawkę naliczania określają reguły naliczania: podstawowa reguła na poziomie zamówienia z typem "punkty za kwotę" to domyślna stawka sklepu.' + earning_rules: Reguły naliczania + earning_rules_card_description: Wyzwalacze, warunki i kwoty + edit_earning_rule: Edytuj regułę naliczania + edit_program: Edytuj program + new_earning_rule: Nowa reguła naliczania + description: Opis + expired: Wygasłe + expires: 'wygasa %date%' + expires_at: Wygasa + expiring_soon: '%points% punktów wygaśnie przed %date%.' + history: Historia transakcji + history_description: + clawback: 'Punkty odebrane za anulowane lub zwrócone zamówienie' + earn_action: 'Punkty zdobyte' + earn_order: 'Zdobyte za zamówienie %order%' + earn_referral: 'Nagroda za polecenie' + expire: 'Punkty wygasły' + manual_credit: 'Korekta: %reason%' + manual_debit: 'Korekta: %reason%' + redeem: 'Wymienione przy zamówieniu %order%' + redeem_rollback: 'Punkty przywrócone z anulowanego zamówienia' + history_empty: Brak transakcji — punkty, które zdobędziesz i wydasz, pojawią się tutaj. + my_loyalty: Mój program lojalnościowy + running_balance: Saldo + inspect: Sprawdź + invariants_hold: Wszystkie niezmienniki księgi są zachowane. + invariants_violated: Niezmienniki księgi zostały naruszone — zbadaj sprawę przed wprowadzeniem ręcznych korekt. + ledger: Księga + lifetime_earned: Zdobyte łącznie + lot: Partia + lots: Partie (wyliczone z odtworzenia) + loyalty: Program lojalnościowy + manual_adjustment: Korekta ręczna + manual_reason: + correction: Korekta + goodwill: Gest dobrej woli + other: Inne + promotion: Promocja + never: nigdy + note: Notatka + points: Punkty + points_amount: '%points% punktów' + points_applied: 'Zastosowano %points% punktów' + points_redemption: Wymiana punktów + program: Program + program_card_description: Przelicznik, wygasanie i zasady dla każdego kanału + reason: Powód + refer_a_friend: Poleć znajomemu + referee: Polecony + referral_explainer: Udostępnij swój link — gdy znajomy złoży swoje pierwsze zamówienie, oboje zdobędziecie punkty. + referral_override: Zatwierdź mimo to + referral_stats: 'Masz %count% nagrodzonych poleceń, które dały %points% punktów.' + referral_status: + expired: Wygasłe + pending: Oczekujące + qualified: Zakwalifikowane + rejected: Odrzucone + rewarded: Nagrodzone + referrals: Polecenia + referrals_card_description: Statusy poleceń i obejścia kontroli antyfraudowych + referrer: Polecający + redemption_clamped: 'Twoje żądanie %requested% punktów jest zastosowane do tego zamówienia tylko częściowo; zwiększy się automatycznie, gdy zamówienie na to pozwoli.' + remaining: Pozostało + remove_redemption: Usuń + rule: Reguła + rule_tester: Tester reguł + rule_tester_card_description: Oceń reguły na niedawnym zamówieniu, opcjonalnie na inną datę + scope: Zakres + share: Udostępnij + copied: Skopiowano! + stat_accounts: Konta + stat_active_accounts_90_days: Aktywne konta (90 dni) + stat_liability: Zobowiązanie z tytułu punktów + stat_redemption_rate_90_days: Wskaźnik wymiany (90 dni) + stat_earned_30_days: Punkty zdobyte (30 dni) + stat_redeemed_30_days: Punkty wymienione (30 dni) + tester_applied: Zastosowano + tester_claimed_basis: Zajęta podstawa + tester_claimed_by: Zajęte przez + tester_evaluate: Oceń + tester_evaluate_at: Oceń tak, jakby było + tester_evaluate_at_help: Podejrzyj zaplanowane reguły, zanim otworzy się ich okno + tester_failed_conditions: Niespełnione warunki + tester_final_award: 'Ostateczne przyznanie: %points% punktów' + tester_invalid_date: Data oceny jest nieprawidłowa. + tester_item_claims: Zajęcia według pozycji + tester_matched: Dopasowano + tester_order_has_no_customer: Zamówienie nie ma klienta, więc nie można ocenić żadnych reguł. + tester_order_not_found: Nie znaleziono zamówienia o tym numerze. + tester_order_number: Numer zamówienia + tester_rules: Oceny reguł + tier: Poziom + tier_progress: '%metric% / %threshold% do %tier%' + tier_top: Masz już najwyższy poziom — dziękujemy, że należysz do grona naszych najlepszych klientów! + tiers: Poziomy + tiers_card_description: Poziomy, progi, mnożniki i korzyści + trigger: Wyzwalacz + use_max: Użyj maksimum + your_code: Twój kod + use_your_points: Wykorzystaj swoje punkty diff --git a/src/Resources/translations/messages.pt.yaml b/src/Resources/translations/messages.pt.yaml new file mode 100644 index 0000000..aff04db --- /dev/null +++ b/src/Resources/translations/messages.pt.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Adicionar condição + amount_configuration: Configuração do montante + amount_configuration_help: 'JSON, p. ex. {"points": 1, "per_amount": 100} para pontos por montante, {"points": 50} para fixo, {"factor": 2} para multiplicador, {"expression": "floor(basis / 100)"} para expressão' + amount_expression: Expressão do montante + amount_expression_help: Usada quando o tipo de montante é "Expressão"; a variável "basis" contém a base reivindicada pela regra em unidades menores + amount_requires_order_trigger: Os tipos por montante e multiplicador precisam de uma base de encomenda e só estão disponíveis para o gatilho de encomenda incorporado. + amount_type: Tipo de montante + condition_configuration: Configuração da condição + condition_configuration_help: 'JSON, p. ex. {"amount": 5000} ou {"taxons": ["t_shirts"]}' + condition_expression: Expressão da condição + condition_expression_help: Usada quando o tipo de condição é "Expressão"; a condição passa quando a expressão é verdadeira + condition_type: Tipo de condição + expression_required: O modo de expressão precisa de uma expressão. + multiplier_is_order_scoped: As regras de multiplicador aplicam-se apenas ao âmbito da encomenda; use o modo de expressão para lógica ao nível do item. + scope_requires_order_trigger: Os âmbitos de taxon e de produto precisam de itens de encomenda e só estão disponíveis para o gatilho de encomenda incorporado. + conditions: Condições + conditions_match: Correspondência das condições + conditions_match_all: Todas as condições devem passar + conditions_match_any: Pelo menos uma condição deve passar + dry_run: Simulação + dry_run_help: Avaliar com o tráfego real e registar na lista de auditoria o que seria atribuído, sem escrever quaisquer pontos + ends_at: Termina em + scope: Âmbito + scope_configuration: Configuração do âmbito + scope_configuration_help: 'JSON, p. ex. {"taxons": ["t_shirts"]} ou {"products": ["MUG"]}; deixe vazio para o âmbito da encomenda' + scope_help: As regras com âmbito de produto ou taxon reivindicam exclusivamente os itens correspondentes; as regras com âmbito de encomenda ganham sobre o restante + scope_order: Encomenda completa + scope_product: Produtos específicos + scope_taxon: Taxons específicos + stackable: Acumulável + stackable_help: As regras acumuláveis que competem pela mesma base aplicam-se todas e somam-se; uma regra não acumulável aplica-se sozinha (ganha a de maior prioridade) + starts_at: Começa em + trigger: Gatilho + window_timezone_help: Introduzido e avaliado no fuso horário configurado da aplicação + earning_rule.amount: + expression: Expressão + fixed: Pontos fixos + multiplier: Multiplicador + per_amount: Pontos por montante + earning_rule.condition: + cart_contains_taxon: O carrinho contém o taxon + customer_group: Grupo de clientes + date_window: Janela de datas + day_of_week: Dia da semana + expression: Expressão + nth_order: Enésima encomenda + order_total_at_least: Total da encomenda no mínimo + invalid_json: Este valor não é JSON válido. + program: + award_moment_order_fulfilled: Quando a encomenda é concluída + award_moment_payment_paid: Quando o pagamento é totalmente pago + award_order_points_at: Atribuir os pontos da encomenda em + award_order_points_at_help: O único momento do ciclo de vida da encomenda em que os pontos são atribuídos — um por programa, nunca por regra + base_currency_help: Os montantes estão em unidades menores da moeda base do canal + clawback_policy: Política de recuperação + clawback_policy_allow_negative: Permitir saldo negativo (recuperado de ganhos futuros) + clawback_policy_clamp_to_zero: Limitar o saldo a zero + earning_basis: Base de ganho + earning_basis_help: O que conta para o ganho; a taxa de ganho em si é definida pelas regras de ganho + earning_basis_items_total: Total dos itens (excl. envio) + earning_basis_order_total: Total da encomenda (incl. envio) + include_taxes: Incluir impostos na base + max_redeem_percent_of_order: Percentagem máx. resgatável da encomenda + max_redeem_percent_of_order_help: A parte do total dos itens da encomenda que pode ser coberta por pontos (100 permite encomendas totalmente gratuitas) + min_redeem_points: Pontos mínimos por resgate + points_expiry_days: Os pontos expiram após (dias) + points_expiry_days_help: Deixe vazio para que os pontos nunca expirem. Note que a expiração de valor armazenado está sujeita a restrições da legislação do consumidor em algumas jurisdições + redemption_conversion_amount: '... valem este montante (unidades menores)' + redemption_conversion_help: 'A conversão de resgate: este número de pontos...' + redemption_conversion_points: Unidade de pontos + retroactive_guest_points: Pontos retroativos de convidado + retroactive_guest_points_help: Atribuir pontos por encomendas de convidado anteriores ao registo quando o convidado se regista (sem retroatividade para registos anteriores) + rounding: Arredondamento + show_earnable_in_cart: Mostrar a dica de pontos no carrinho + show_earnable_on_product: Mostrar a dica de pontos nas páginas de produto + tier_downgrade_grace_days: Tolerância antes da descida de nível (dias) + tier_downgrade_grace_days_help: Durante quanto tempo uma conta mantém o seu nível depois de cair abaixo do limiar; 0 desce de nível na avaliação noturna seguinte + tier_evaluation_window: Janela de avaliação de níveis + tier_window_calendar_year: Ano civil + tier_window_lifetime: Vitalício + tier_window_rolling_12_months: 12 meses móveis + rounding_ceil: Por excesso + rounding_floor: Por defeito + rounding_round: Arredondar + promotion_rule: + customer_loyalty_tier: O nível de fidelidade do cliente é pelo menos + minimum_tier: Nível mínimo + tier: + benefits: Benefícios + benefits_description: Descrição dos benefícios + color: Cor do emblema + earning_multiplier: Multiplicador de ganho + earning_multiplier_help: Aplicado a cada ganho de pontos enquanto a conta está neste nível (1 = sem alteração) + position_help: Posições mais altas são níveis mais altos; a posição mais alta é o nível de topo + qualification_basis: Base de qualificação + threshold: Limiar + threshold_help: A unidade depende da base (pontos, unidades menores de moeda ou encomendas) + tier_basis: + amount_spent: Montante gasto + orders_count: Encomendas efetuadas + points_earned: Pontos ganhos + unit: + currency: unidades menores de moeda + orders: encomendas + points: pontos + trigger: + customer_birthday: Aniversário do cliente + customer_registered: Cliente registado + order_eligible: Encomenda (incorporado) + product_review_approved: Avaliação de produto aprovada + ui: + account: Conta de fidelidade + account_inactive: A sua conta de fidelidade está atualmente inativa — o seu saldo está preservado, mas o ganho e o resgate estão em pausa. + accounts: Contas de fidelidade + accounts_card_description: Consultar contas, inspecionar livros-razão, ajustar pontos + adjust: Ajustar + anonymized: Anonimizado + balance: O seu saldo de pontos + balance_label: Saldo + change_on_cart: Alterar no carrinho + consumptions: Consumido por + dashboard_subtitle: Pontos, regras de ganho e definições do programa + derived_balance: Soma do livro-razão + dry_run: Simulação + dry_run_results: Resultados de simulação + dry_run_results_card_description: O que as regras em simulação teriam atribuído + earn_hint_cart: 'Esta encomenda ganha ~%points% pontos.' + earn_hint_product: 'Ganhará %points% pontos ao comprar este produto.' + earning_rate_hint: 'A taxa de ganho é definida pelas regras de ganho: uma regra base com âmbito de encomenda e o tipo por montante é a taxa padrão da loja.' + earning_rules: Regras de ganho + earning_rules_card_description: Gatilhos, condições e montantes + edit_earning_rule: Editar regra de ganho + edit_program: Editar programa + new_earning_rule: Nova regra de ganho + description: Descrição + expired: Expirado + expires: 'expira %date%' + expires_at: Expira em + expiring_soon: '%points% pontos expiram antes de %date%.' + history: Histórico de transações + history_description: + clawback: 'Pontos recuperados por uma encomenda cancelada ou reembolsada' + earn_action: 'Pontos ganhos' + earn_order: 'Ganhos na encomenda %order%' + earn_referral: 'Recompensa de indicação' + expire: 'Pontos expirados' + manual_credit: 'Ajuste: %reason%' + manual_debit: 'Ajuste: %reason%' + redeem: 'Resgatados na encomenda %order%' + redeem_rollback: 'Pontos restaurados de uma encomenda cancelada' + history_empty: Ainda sem transações — os pontos que ganhar e gastar aparecerão aqui. + my_loyalty: A minha fidelidade + running_balance: Saldo + inspect: Inspecionar + invariants_hold: Todos os invariantes do livro-razão são cumpridos. + invariants_violated: Os invariantes do livro-razão foram violados — investigue antes de fazer correções manuais. + ledger: Livro-razão + lifetime_earned: Total ganho acumulado + lot: Lote + lots: Lotes (derivados por reprodução) + loyalty: Fidelidade + manual_adjustment: Ajuste manual + manual_reason: + correction: Correção + goodwill: Cortesia + other: Outro + promotion: Promoção + never: nunca + note: Nota + points: Pontos + points_amount: '%points% pontos' + points_applied: '%points% pontos aplicados' + points_redemption: Resgate de pontos + program: Programa + program_card_description: Conversão, expiração e políticas por canal + reason: Motivo + refer_a_friend: Indique um amigo + referee: Indicado + referral_explainer: Partilhe a sua ligação — quando um amigo fizer a primeira encomenda, ambos ganham pontos. + referral_override: Aprovar mesmo assim + referral_stats: 'Tem %count% indicação(ões) recompensada(s), que renderam %points% pontos.' + referral_status: + expired: Expirada + pending: Pendente + qualified: Qualificada + rejected: Rejeitada + rewarded: Recompensada + referrals: Indicações + referrals_card_description: Estados das indicações e anulação de rejeições por fraude + referrer: Indicador + redemption_clamped: 'O seu pedido de %requested% pontos é aplicado apenas parcialmente a esta encomenda; volta a aumentar automaticamente quando a encomenda o permitir.' + remaining: Restante + remove_redemption: Remover + rule: Regra + rule_tester: Testador de regras + rule_tester_card_description: Avaliar regras com uma encomenda recente, opcionalmente noutra data + scope: Âmbito + share: Partilhar + copied: Copiado! + stat_accounts: Contas + stat_active_accounts_90_days: Contas ativas (90 dias) + stat_liability: Passivo de pontos em aberto + stat_redemption_rate_90_days: Taxa de resgate (90 dias) + stat_earned_30_days: Pontos ganhos (30 dias) + stat_redeemed_30_days: Pontos resgatados (30 dias) + tester_applied: Aplicada + tester_claimed_basis: Base reivindicada + tester_claimed_by: Reivindicada por + tester_evaluate: Avaliar + tester_evaluate_at: Avaliar como se fosse + tester_evaluate_at_help: Pré-visualizar regras agendadas antes de a sua janela abrir + tester_failed_conditions: Condições falhadas + tester_final_award: 'Atribuição final: %points% pontos' + tester_invalid_date: A data de avaliação é inválida. + tester_item_claims: Reivindicações por item + tester_matched: Correspondeu + tester_order_has_no_customer: A encomenda não tem cliente, pelo que nenhuma regra pode ser avaliada. + tester_order_not_found: Não foi encontrada nenhuma encomenda com esse número. + tester_order_number: Número da encomenda + tester_rules: Avaliações de regras + tier: Nível + tier_progress: '%metric% / %threshold% até %tier%' + tier_top: Atingiu o nível mais alto — obrigado por ser um dos nossos melhores clientes! + tiers: Níveis + tiers_card_description: Níveis, limiares, multiplicadores e benefícios + trigger: Gatilho + use_max: Usar o máximo + your_code: O seu código + use_your_points: Use os seus pontos diff --git a/src/Resources/translations/messages.ro.yaml b/src/Resources/translations/messages.ro.yaml new file mode 100644 index 0000000..539d699 --- /dev/null +++ b/src/Resources/translations/messages.ro.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Adaugă condiție + amount_configuration: Configurarea sumei + amount_configuration_help: 'JSON, de ex. {"points": 1, "per_amount": 100} pentru puncte per sumă, {"points": 50} pentru fix, {"factor": 2} pentru multiplicator, {"expression": "floor(basis / 100)"} pentru expresie' + amount_expression: Expresia sumei + amount_expression_help: Folosită când tipul sumei este „Expresie”; variabila "basis" conține baza revendicată de regulă, în subunități monetare + amount_requires_order_trigger: Tipurile per sumă și multiplicator au nevoie de o bază de comandă și sunt disponibile doar pentru declanșatorul de comandă încorporat. + amount_type: Tipul sumei + condition_configuration: Configurarea condiției + condition_configuration_help: 'JSON, de ex. {"amount": 5000} sau {"taxons": ["t_shirts"]}' + condition_expression: Expresia condiției + condition_expression_help: Folosită când tipul condiției este „Expresie”; condiția trece când expresia este adevărată + condition_type: Tipul condiției + expression_required: Modul expresie are nevoie de o expresie. + multiplier_is_order_scoped: Regulile cu multiplicator se aplică doar la nivelul comenzii; folosiți modul expresie pentru logică la nivel de articol. + scope_requires_order_trigger: Domeniile pe taxon și pe produs au nevoie de articolele comenzii și sunt disponibile doar pentru declanșatorul de comandă încorporat. + conditions: Condiții + conditions_match: Potrivirea condițiilor + conditions_match_all: Toate condițiile trebuie să treacă + conditions_match_any: Cel puțin o condiție trebuie să treacă + dry_run: Simulare + dry_run_help: Evaluează pe traficul real și înregistrează în lista de audit ce ar fi fost acordat, fără a scrie niciun punct + ends_at: Se încheie la + scope: Domeniu + scope_configuration: Configurarea domeniului + scope_configuration_help: 'JSON, de ex. {"taxons": ["t_shirts"]} sau {"products": ["MUG"]}; lăsați gol pentru domeniul comenzii' + scope_help: Regulile cu domeniu pe produs sau taxon își revendică exclusiv articolele potrivite; regulile cu domeniu pe comandă acumulează pe restul + scope_order: Întreaga comandă + scope_product: Produse specifice + scope_taxon: Taxoni specifici + stackable: Cumulabilă + stackable_help: Regulile cumulabile care concurează pentru aceeași bază se aplică toate și se însumează; o regulă necumulabilă se aplică singură (câștigă prioritatea cea mai mare) + starts_at: Începe la + trigger: Declanșator + window_timezone_help: Se introduce și se evaluează în fusul orar configurat al aplicației + earning_rule.amount: + expression: Expresie + fixed: Puncte fixe + multiplier: Multiplicator + per_amount: Puncte per sumă + earning_rule.condition: + cart_contains_taxon: Coșul conține taxonul + customer_group: Grup de clienți + date_window: Interval de date + day_of_week: Ziua săptămânii + expression: Expresie + nth_order: A n-a comandă + order_total_at_least: Totalul comenzii de cel puțin + invalid_json: Această valoare nu este un JSON valid. + program: + award_moment_order_fulfilled: Când comanda este finalizată + award_moment_payment_paid: Când plata este achitată integral + award_order_points_at: Acordă punctele comenzii la + award_order_points_at_help: Singurul moment din ciclul de viață al comenzii în care se acordă punctele — unul per program, niciodată per regulă + base_currency_help: Sumele sunt în subunități ale monedei de bază a canalului + clawback_policy: Politica de recuperare + clawback_policy_allow_negative: Permite sold negativ (recuperat din acumulările viitoare) + clawback_policy_clamp_to_zero: Limitează soldul la zero + earning_basis: Baza de acumulare + earning_basis_help: Ce se ia în calcul la acumulare; rata de acumulare propriu-zisă este definită de regulile de acumulare + earning_basis_items_total: Totalul articolelor (fără livrare) + earning_basis_order_total: Totalul comenzii (cu livrare) + include_taxes: Include taxele în bază + max_redeem_percent_of_order: Procent max. din comandă utilizabil + max_redeem_percent_of_order_help: Partea din totalul articolelor comenzii care poate fi acoperită cu puncte (100 permite comenzi complet gratuite) + min_redeem_points: Puncte minime per utilizare + points_expiry_days: Punctele expiră după (zile) + points_expiry_days_help: Lăsați gol pentru ca punctele să nu expire niciodată. Rețineți că expirarea valorii stocate este supusă, în unele jurisdicții, restricțiilor legislației privind protecția consumatorului + redemption_conversion_amount: '... valorează această sumă (subunități)' + redemption_conversion_help: 'Conversia la utilizare: acest număr de puncte...' + redemption_conversion_points: Unitatea de puncte + retroactive_guest_points: Puncte retroactive pentru vizitatori + retroactive_guest_points_help: Acordă puncte pentru comenzile plasate ca vizitator înainte de înregistrare, atunci când vizitatorul se înregistrează (fără acordare retroactivă pentru înregistrările anterioare) + rounding: Rotunjire + show_earnable_in_cart: Afișează indiciul de puncte în coș + show_earnable_on_product: Afișează indiciul de puncte pe paginile de produs + tier_downgrade_grace_days: Perioadă de grație la retrogradarea nivelului (zile) + tier_downgrade_grace_days_help: Cât timp un cont își păstrează nivelul după ce a scăzut sub prag; 0 retrogradează la următoarea evaluare nocturnă + tier_evaluation_window: Fereastra de evaluare a nivelurilor + tier_window_calendar_year: An calendaristic + tier_window_lifetime: Pe viață + tier_window_rolling_12_months: 12 luni glisante + rounding_ceil: În sus + rounding_floor: În jos + rounding_round: Matematică + promotion_rule: + customer_loyalty_tier: Nivelul de fidelitate al clientului este cel puțin + minimum_tier: Nivel minim + tier: + benefits: Beneficii + benefits_description: Descrierea beneficiilor + color: Culoarea insignei + earning_multiplier: Multiplicator de acumulare + earning_multiplier_help: Se aplică fiecărei acumulări de puncte cât timp contul este la acest nivel (1 = nicio schimbare) + position_help: Pozițiile mai mari sunt niveluri mai înalte; poziția cea mai mare este nivelul de top + qualification_basis: Baza de calificare + threshold: Prag + threshold_help: Unitatea depinde de bază (puncte, subunități ale monedei sau comenzi) + tier_basis: + amount_spent: Suma cheltuită + orders_count: Comenzi plasate + points_earned: Puncte acumulate + unit: + currency: subunități ale monedei + orders: comenzi + points: puncte + trigger: + customer_birthday: Ziua de naștere a clientului + customer_registered: Client înregistrat + order_eligible: Comandă (încorporat) + product_review_approved: Recenzie de produs aprobată + ui: + account: Cont de fidelitate + account_inactive: Contul dumneavoastră de fidelitate este momentan inactiv — soldul este păstrat, dar acumularea și utilizarea punctelor sunt suspendate. + accounts: Conturi de fidelitate + accounts_card_description: Răsfoiți conturile, inspectați registrele, ajustați punctele + adjust: Ajustează + anonymized: Anonimizat + balance: Soldul dumneavoastră de puncte + balance_label: Sold + change_on_cart: Modifică în coș + consumptions: Consumat de + dashboard_subtitle: Puncte, reguli de acumulare și setările programului + derived_balance: Suma din registru + dry_run: Simulare + dry_run_results: Rezultatele simulării + dry_run_results_card_description: Ce ar fi acordat regulile în simulare + earn_hint_cart: 'Această comandă acumulează ~%points% puncte.' + earn_hint_product: 'Veți acumula %points% puncte cumpărând acest produs.' + earning_rate_hint: 'Rata de acumulare este definită de regulile de acumulare: o regulă de bază la nivel de comandă, de tip per sumă, este rata implicită a magazinului.' + earning_rules: Reguli de acumulare + earning_rules_card_description: Declanșatoare, condiții și sume + edit_earning_rule: Editează regula de acumulare + edit_program: Editează programul + new_earning_rule: Regulă de acumulare nouă + description: Descriere + expired: Expirat + expires: 'expiră %date%' + expires_at: Expiră la + expiring_soon: '%points% puncte expiră înainte de %date%.' + history: Istoricul tranzacțiilor + history_description: + clawback: 'Puncte recuperate pentru o comandă anulată sau rambursată' + earn_action: 'Puncte acumulate' + earn_order: 'Acumulate la comanda %order%' + earn_referral: 'Recompensă pentru recomandare' + expire: 'Puncte expirate' + manual_credit: 'Ajustare: %reason%' + manual_debit: 'Ajustare: %reason%' + redeem: 'Utilizate la comanda %order%' + redeem_rollback: 'Puncte restituite dintr-o comandă anulată' + history_empty: Nicio tranzacție încă — punctele pe care le acumulați și le cheltuiți vor apărea aici. + my_loyalty: Fidelitatea mea + running_balance: Sold + inspect: Inspectează + invariants_hold: Toți invarianții registrului sunt respectați. + invariants_violated: Invarianții registrului sunt încălcați — investigați înainte de a face corecții manuale. + ledger: Registru + lifetime_earned: Total acumulat + lot: Lot + lots: Loturi (derivate prin reluare) + loyalty: Fidelitate + manual_adjustment: Ajustare manuală + manual_reason: + correction: Corecție + goodwill: Bunăvoință + other: Altul + promotion: Promoție + never: niciodată + note: Notă + points: Puncte + points_amount: '%points% puncte' + points_applied: '%points% puncte aplicate' + points_redemption: Utilizarea punctelor + program: Program + program_card_description: Conversie, expirare și politici per canal + reason: Motiv + refer_a_friend: Recomandați unui prieten + referee: Persoana recomandată + referral_explainer: Distribuiți linkul dumneavoastră — când un prieten plasează prima sa comandă, amândoi acumulați puncte. + referral_override: Aprobă oricum + referral_stats: 'Aveți %count% recomandări recompensate, care au adus %points% puncte.' + referral_status: + expired: Expirată + pending: În așteptare + qualified: Calificată + rejected: Respinsă + rewarded: Recompensată + referrals: Recomandări + referrals_card_description: Stările recomandărilor și anularea respingerilor pentru fraudă + referrer: Persoana care recomandă + redemption_clamped: 'Cererea dumneavoastră de %requested% puncte este aplicată doar parțial acestei comenzi; ea crește la loc automat când comanda o permite.' + remaining: Rămas + remove_redemption: Elimină + rule: Regulă + rule_tester: Tester de reguli + rule_tester_card_description: Evaluați regulile pe o comandă recentă, opțional la o altă dată + scope: Domeniu + share: Distribuie + copied: Copiat! + stat_accounts: Conturi + stat_active_accounts_90_days: Conturi active (90 de zile) + stat_liability: Datorie de puncte în circulație + stat_redemption_rate_90_days: Rata de utilizare (90 de zile) + stat_earned_30_days: Puncte acumulate (30 de zile) + stat_redeemed_30_days: Puncte utilizate (30 de zile) + tester_applied: Aplicată + tester_claimed_basis: Baza revendicată + tester_claimed_by: Revendicată de + tester_evaluate: Evaluează + tester_evaluate_at: Evaluează ca și cum ar fi + tester_evaluate_at_help: Previzualizați regulile programate înainte de deschiderea intervalului lor + tester_failed_conditions: Condiții neîndeplinite + tester_final_award: 'Acordare finală: %points% puncte' + tester_invalid_date: Data evaluării este invalidă. + tester_item_claims: Revendicări per articol + tester_matched: S-a potrivit + tester_order_has_no_customer: Comanda nu are client, așa că nu se pot evalua reguli. + tester_order_not_found: Nu a fost găsită nicio comandă cu acest număr. + tester_order_number: Numărul comenzii + tester_rules: Evaluările regulilor + tier: Nivel + tier_progress: '%metric% / %threshold% până la %tier%' + tier_top: Ați atins nivelul cel mai înalt — vă mulțumim că sunteți unul dintre cei mai buni clienți ai noștri! + tiers: Niveluri + tiers_card_description: Niveluri, praguri, multiplicatori și beneficii + trigger: Declanșator + use_max: Folosește maximul + your_code: Codul dumneavoastră + use_your_points: Folosiți-vă punctele diff --git a/src/Resources/translations/messages.sv.yaml b/src/Resources/translations/messages.sv.yaml new file mode 100644 index 0000000..9de5421 --- /dev/null +++ b/src/Resources/translations/messages.sv.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Lägg till villkor + amount_configuration: Beloppskonfiguration + amount_configuration_help: 'JSON, t.ex. {"points": 1, "per_amount": 100} för per belopp, {"points": 50} för fast, {"factor": 2} för multiplikator, {"expression": "floor(basis / 100)"} för uttryck' + amount_expression: Beloppsuttryck + amount_expression_help: Används när beloppstypen är "Uttryck"; variabeln "basis" innehåller den bas som regeln gör anspråk på, i minsta enheter + amount_requires_order_trigger: Typerna per belopp och multiplikator kräver en orderbas och är endast tillgängliga för den inbyggda ordertriggern. + amount_type: Beloppstyp + condition_configuration: Villkorskonfiguration + condition_configuration_help: 'JSON, t.ex. {"amount": 5000} eller {"taxons": ["t_shirts"]}' + condition_expression: Villkorsuttryck + condition_expression_help: Används när villkorstypen är "Uttryck"; villkoret uppfylls när uttrycket utvärderas som sant + condition_type: Villkorstyp + expression_required: Uttrycksläget kräver ett uttryck. + multiplier_is_order_scoped: Multiplikatorregler gäller endast hela ordern; använd uttrycksläget för logik på radnivå. + scope_requires_order_trigger: Taxon- och produktavgränsningar kräver orderrader och är endast tillgängliga för den inbyggda ordertriggern. + conditions: Villkor + conditions_match: Matchning av villkor + conditions_match_all: Alla villkor måste uppfyllas + conditions_match_any: Minst ett villkor måste uppfyllas + dry_run: Testkörning + dry_run_help: Utvärdera mot verklig trafik och logga till granskningslistan vad som skulle ha tilldelats, utan att skriva några poäng + ends_at: Slutar + scope: Avgränsning + scope_configuration: Avgränsningskonfiguration + scope_configuration_help: 'JSON, t.ex. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; lämna tomt för hela ordern' + scope_help: Produkt- eller taxonavgränsade regler gör exklusivt anspråk på sina matchande artiklar; orderavgränsade regler tjänar in på resten + scope_order: Hela ordern + scope_product: Specifika produkter + scope_taxon: Specifika taxoner + stackable: Kombinerbar + stackable_help: Kombinerbara regler som konkurrerar om samma bas gäller alla och summeras; en icke-kombinerbar regel gäller ensam (högsta prioritet vinner) + starts_at: Börjar + trigger: Trigger + window_timezone_help: Anges och utvärderas i applikationens konfigurerade tidszon + earning_rule.amount: + expression: Uttryck + fixed: Fasta poäng + multiplier: Multiplikator + per_amount: Poäng per belopp + earning_rule.condition: + cart_contains_taxon: Varukorgen innehåller taxon + customer_group: Kundgrupp + date_window: Datumintervall + day_of_week: Veckodag + expression: Uttryck + nth_order: 'N:te ordern' + order_total_at_least: Ordersumma minst + invalid_json: Detta värde är inte giltig JSON. + program: + award_moment_order_fulfilled: När ordern är slutförd + award_moment_payment_paid: När betalningen är helt betald + award_order_points_at: Tidpunkt för tilldelning av orderpoäng + award_order_points_at_help: Det enda tillfället i orderns livscykel då poäng tilldelas — ett per program, aldrig per regel + base_currency_help: Belopp anges i minsta enheter av kanalens basvaluta + clawback_policy: Återtagspolicy + clawback_policy_allow_negative: Tillåt negativt saldo (återvinns från framtida intjäning) + clawback_policy_clamp_to_zero: Begränsa saldot till noll + earning_basis: Intjäningsbas + earning_basis_help: Vad som räknas in i intjäningen; själva intjäningstakten definieras av intjäningsregler + earning_basis_items_total: Artikelsumma (exkl. frakt) + earning_basis_order_total: Ordersumma (inkl. frakt) + include_taxes: Inkludera moms i basen + max_redeem_percent_of_order: Max. inlösbar procent av ordern + max_redeem_percent_of_order_help: Den andel av orderns artikelsumma som kan täckas av poäng (100 tillåter helt gratis ordrar) + min_redeem_points: Minsta antal poäng per inlösen + points_expiry_days: Poäng förfaller efter (dagar) + points_expiry_days_help: Lämna tomt så förfaller poängen aldrig. Observera att förfall av lagrat värde kan omfattas av konsumenträttsliga begränsningar i vissa jurisdiktioner + redemption_conversion_amount: '... är värda detta belopp (minsta enheter)' + redemption_conversion_help: 'Inlösenkursen: så här många poäng...' + redemption_conversion_points: Poängenhet + retroactive_guest_points: Retroaktiva gästpoäng + retroactive_guest_points_help: Tilldela poäng för gästordrar lagda före registreringen när gästen registrerar sig (ingen retroaktiv tilldelning för tidigare registreringar) + rounding: Avrundning + show_earnable_in_cart: Visa intjäningstipset i varukorgen + show_earnable_on_product: Visa intjäningstipset på produktsidor + tier_downgrade_grace_days: Frist vid nivånedflyttning (dagar) + tier_downgrade_grace_days_help: Hur länge ett konto behåller sin nivå efter att ha hamnat under tröskeln; 0 flyttar ned vid nästa nattliga utvärdering + tier_evaluation_window: Utvärderingsfönster för nivåer + tier_window_calendar_year: Kalenderår + tier_window_lifetime: Livstid + tier_window_rolling_12_months: Rullande 12 månader + rounding_ceil: Uppåt + rounding_floor: Nedåt + rounding_round: Avrunda + promotion_rule: + customer_loyalty_tier: Kundens lojalitetsnivå är minst + minimum_tier: Lägsta nivå + tier: + benefits: Förmåner + benefits_description: Beskrivning av förmånerna + color: Badgefärg + earning_multiplier: Intjäningsmultiplikator + earning_multiplier_help: Tillämpas på all poängintjäning medan kontot är på denna nivå (1 = ingen ändring) + position_help: Högre positioner är högre nivåer; den högsta positionen är toppnivån + qualification_basis: Kvalificeringsgrund + threshold: Tröskel + threshold_help: Enheten beror på grunden (poäng, minsta valutaenheter eller order) + tier_basis: + amount_spent: Spenderat belopp + orders_count: Lagda order + points_earned: Intjänade poäng + unit: + currency: minsta valutaenheter + orders: order + points: poäng + trigger: + customer_birthday: Kundens födelsedag + customer_registered: Kund registrerad + order_eligible: Order (inbyggd) + product_review_approved: Produktrecension godkänd + ui: + account: Lojalitetskonto + account_inactive: Ditt lojalitetskonto är för närvarande inaktivt — ditt saldo är bevarat, men intjäning och inlösen är pausade. + accounts: Lojalitetskonton + accounts_card_description: Bläddra bland konton, granska huvudböcker, justera poäng + adjust: Justera + anonymized: Anonymiserad + balance: Ditt poängsaldo + balance_label: Saldo + change_on_cart: Ändra i varukorgen + consumptions: Förbrukad av + dashboard_subtitle: Poäng, intjäningsregler och programinställningar + derived_balance: Summa i huvudboken + dry_run: Testkörning + dry_run_results: Testkörningsresultat + dry_run_results_card_description: Vad testkörningsregler skulle ha tilldelat + earn_hint_cart: 'Denna order ger ~%points% poäng.' + earn_hint_product: 'Du tjänar %points% poäng när du köper denna produkt.' + earning_rate_hint: 'Intjäningstakten definieras av intjäningsregler: en basregel för hela ordern med typen per belopp är butikens standardtakt.' + earning_rules: Intjäningsregler + earning_rules_card_description: Triggrar, villkor och belopp + edit_earning_rule: Redigera intjäningsregel + edit_program: Redigera program + new_earning_rule: Ny intjäningsregel + description: Beskrivning + expired: Förfallen + expires: 'förfaller %date%' + expires_at: Förfaller + expiring_soon: '%points% poäng förfaller före %date%.' + history: Transaktionshistorik + history_description: + clawback: 'Poäng återtagna för en annullerad eller återbetald order' + earn_action: 'Poäng intjänade' + earn_order: 'Intjänade på order %order%' + earn_referral: 'Värvningsbelöning' + expire: 'Poäng förfallna' + manual_credit: 'Justering: %reason%' + manual_debit: 'Justering: %reason%' + redeem: 'Inlösta på order %order%' + redeem_rollback: 'Poäng återställda från en annullerad order' + history_empty: Inga transaktioner ännu — poäng du tjänar in och använder visas här. + my_loyalty: Mitt lojalitetsprogram + running_balance: Saldo + inspect: Granska + invariants_hold: Alla huvudbokens invarianter håller. + invariants_violated: Huvudbokens invarianter är brutna — utred innan manuella korrigeringar görs. + ledger: Huvudbok + lifetime_earned: Intjänat totalt + lot: Lot + lots: Lotter (härledda via återuppspelning) + loyalty: Lojalitet + manual_adjustment: Manuell justering + manual_reason: + correction: Korrigering + goodwill: Goodwill + other: Övrigt + promotion: Kampanj + never: aldrig + note: Anteckning + points: Poäng + points_amount: '%points% poäng' + points_applied: '%points% poäng tillämpade' + points_redemption: Poänginlösen + program: Program + program_card_description: Konvertering, förfall och policyer per kanal + reason: Orsak + refer_a_friend: Värva en vän + referee: Värvad + referral_explainer: Dela din länk — när en vän lägger sin första order tjänar ni båda poäng. + referral_override: Godkänn ändå + referral_stats: 'Du har %count% belönade värvning(ar) som har gett %points% poäng.' + referral_status: + expired: Förfallen + pending: Väntande + qualified: Kvalificerad + rejected: Avvisad + rewarded: Belönad + referrals: Värvningar + referrals_card_description: Värvningsstatusar och åsidosättning av bedrägerikontroller + referrer: Värvare + redemption_clamped: 'Din begäran om %requested% poäng tillämpas endast delvis på denna order; den ökar automatiskt igen när ordern tillåter det.' + remaining: Återstående + remove_redemption: Ta bort + rule: Regel + rule_tester: Regeltestare + rule_tester_card_description: Utvärdera regler mot en nyligen lagd order, eventuellt vid ett annat datum + scope: Avgränsning + share: Dela + copied: Kopierad! + stat_accounts: Konton + stat_active_accounts_90_days: Aktiva konton (90 dagar) + stat_liability: Utestående poängskuld + stat_redemption_rate_90_days: Inlösengrad (90 dagar) + stat_earned_30_days: Poäng intjänade (30 dagar) + stat_redeemed_30_days: Poäng inlösta (30 dagar) + tester_applied: Tillämpad + tester_claimed_basis: Bas i anspråk + tester_claimed_by: I anspråk av + tester_evaluate: Utvärdera + tester_evaluate_at: Utvärdera som om det vore + tester_evaluate_at_help: Förhandsgranska schemalagda regler innan deras fönster öppnas + tester_failed_conditions: Ej uppfyllda villkor + tester_final_award: 'Slutlig tilldelning: %points% poäng' + tester_invalid_date: Utvärderingsdatumet är ogiltigt. + tester_item_claims: Anspråk per artikel + tester_matched: Matchad + tester_order_has_no_customer: Ordern har ingen kund, så inga regler kan utvärderas. + tester_order_not_found: Ingen order med det numret hittades. + tester_order_number: Ordernummer + tester_rules: Regelutvärderingar + tier: Nivå + tier_progress: '%metric% / %threshold% till %tier%' + tier_top: Du har nått den högsta nivån — tack för att du är en av våra allra bästa kunder! + tiers: Nivåer + tiers_card_description: Nivåer, trösklar, multiplikatorer och förmåner + trigger: Trigger + use_max: Använd max + your_code: Din kod + use_your_points: Använd dina poäng diff --git a/src/Resources/translations/messages.uk.yaml b/src/Resources/translations/messages.uk.yaml new file mode 100644 index 0000000..aa84df8 --- /dev/null +++ b/src/Resources/translations/messages.uk.yaml @@ -0,0 +1,233 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + form: + earning_rule: + add_condition: Додати умову + amount_configuration: Налаштування суми + amount_configuration_help: 'JSON, напр. {"points": 1, "per_amount": 100} для балів за суму, {"points": 50} для фіксованої кількості, {"factor": 2} для множника, {"expression": "floor(basis / 100)"} для виразу' + amount_expression: Вираз суми + amount_expression_help: Використовується, коли тип суми — «Вираз»; змінна "basis" містить базу, заявлену правилом, у мінімальних грошових одиницях + amount_requires_order_trigger: Типи «бали за суму» та «множник» потребують бази замовлення й доступні лише для вбудованого тригера замовлення. + amount_type: Тип суми + condition_configuration: Налаштування умови + condition_configuration_help: 'JSON, напр. {"amount": 5000} або {"taxons": ["t_shirts"]}' + condition_expression: Вираз умови + condition_expression_help: Використовується, коли тип умови — «Вираз»; умова виконується, коли вираз істинний + condition_type: Тип умови + expression_required: Режим виразу потребує виразу. + multiplier_is_order_scoped: Правила з множником діють лише на рівні замовлення; для логіки на рівні позицій використовуйте режим виразу. + scope_requires_order_trigger: Області дії за таксоном і товаром потребують позицій замовлення й доступні лише для вбудованого тригера замовлення. + conditions: Умови + conditions_match: Відповідність умов + conditions_match_all: Мають виконуватися всі умови + conditions_match_any: Має виконуватися принаймні одна умова + dry_run: Тестовий запуск + dry_run_help: Оцінювати на реальному трафіку та записувати до списку аудиту, що було б нараховано, без запису жодних балів + ends_at: Завершується + scope: Область дії + scope_configuration: Налаштування області дії + scope_configuration_help: 'JSON, напр. {"taxons": ["t_shirts"]} або {"products": ["MUG"]}; залиште порожнім для області замовлення' + scope_help: Правила з областю дії за товаром або таксоном ексклюзивно забирають відповідні позиції; правила на рівні замовлення нараховують бали на решту + scope_order: Усе замовлення + scope_product: Певні товари + scope_taxon: Певні таксони + stackable: Сумісне з іншими + stackable_help: Сумісні правила, що претендують на ту саму базу, застосовуються всі разом і сумуються; несумісне правило застосовується самостійно (перемагає найвищий пріоритет) + starts_at: Починається + trigger: Тригер + window_timezone_help: Вводиться та оцінюється в часовому поясі, налаштованому в застосунку + earning_rule.amount: + expression: Вираз + fixed: Фіксовані бали + multiplier: Множник + per_amount: Бали за суму + earning_rule.condition: + cart_contains_taxon: Кошик містить таксон + customer_group: Група клієнтів + date_window: Проміжок дат + day_of_week: День тижня + expression: Вираз + nth_order: N-не замовлення + order_total_at_least: Сума замовлення щонайменше + invalid_json: Це значення не є коректним JSON. + program: + award_moment_order_fulfilled: Коли замовлення виконано + award_moment_payment_paid: Коли оплату здійснено повністю + award_order_points_at: Нараховувати бали за замовлення в момент + award_order_points_at_help: Єдиний момент життєвого циклу замовлення, коли нараховуються бали — один на програму, ніколи не на правило + base_currency_help: Суми вказуються в мінімальних одиницях базової валюти каналу + clawback_policy: Політика повернення балів + clawback_policy_allow_negative: Дозволити відʼємний баланс (компенсується з майбутніх нарахувань) + clawback_policy_clamp_to_zero: Обмежити баланс нулем + earning_basis: База нарахування + earning_basis_help: Що враховується під час нарахування; саму ставку нарахування визначають правила нарахування + earning_basis_items_total: Сума позицій (без доставки) + earning_basis_order_total: Сума замовлення (з доставкою) + include_taxes: Включати податки до бази + max_redeem_percent_of_order: Макс. відсоток замовлення для списання + max_redeem_percent_of_order_help: Частка суми позицій замовлення, яку можна покрити балами (100 дозволяє повністю безкоштовні замовлення) + min_redeem_points: Мінімум балів на одне списання + points_expiry_days: Бали спливають через (днів) + points_expiry_days_help: Залиште порожнім, щоб бали ніколи не спливали. Зауважте, що в деяких юрисдикціях сплив накопиченої вартості підпадає під обмеження законодавства про захист споживачів + redemption_conversion_amount: '... варті цієї суми (мінімальні одиниці)' + redemption_conversion_help: 'Конвертація при списанні: така кількість балів...' + redemption_conversion_points: Одиниця балів + retroactive_guest_points: Ретроактивні бали гостей + retroactive_guest_points_help: Нараховувати бали за гостьові замовлення, зроблені до реєстрації, коли гість реєструється (без донарахування для раніших реєстрацій) + rounding: Округлення + show_earnable_in_cart: Показувати підказку про бали в кошику + show_earnable_on_product: Показувати підказку про бали на сторінках товарів + tier_downgrade_grace_days: Пільговий період перед пониженням рівня (днів) + tier_downgrade_grace_days_help: Як довго рахунок зберігає свій рівень після падіння нижче порога; 0 — пониження під час наступної нічної переоцінки + tier_evaluation_window: Вікно оцінювання рівнів + tier_window_calendar_year: Календарний рік + tier_window_lifetime: За весь час + tier_window_rolling_12_months: Ковзні 12 місяців + rounding_ceil: Догори + rounding_floor: Донизу + rounding_round: Математичне + promotion_rule: + customer_loyalty_tier: Рівень лояльності клієнта не нижчий за + minimum_tier: Мінімальний рівень + tier: + benefits: Переваги + benefits_description: Опис переваг + color: Колір значка + earning_multiplier: Множник нарахування + earning_multiplier_help: Застосовується до кожного нарахування балів, поки рахунок перебуває на цьому рівні (1 = без змін) + position_help: Вищі позиції — це вищі рівні; найвища позиція є найвищим рівнем + qualification_basis: База кваліфікації + threshold: Поріг + threshold_help: Одиниця залежить від бази (бали, мінімальні одиниці валюти або замовлення) + tier_basis: + amount_spent: Витрачена сума + orders_count: Розміщені замовлення + points_earned: Нараховані бали + unit: + currency: мінімальні одиниці валюти + orders: замовлення + points: бали + trigger: + customer_birthday: День народження клієнта + customer_registered: Клієнт зареєструвався + order_eligible: Замовлення (вбудований) + product_review_approved: Відгук про товар схвалено + ui: + account: Рахунок лояльності + account_inactive: Ваш рахунок лояльності наразі неактивний — баланс збережено, але нарахування та списання балів призупинені. + accounts: Рахунки лояльності + accounts_card_description: Перегляд рахунків, аналіз журналів, коригування балів + adjust: Коригувати + anonymized: Анонімізовано + balance: Ваш баланс балів + balance_label: Баланс + change_on_cart: Змінити в кошику + consumptions: Використано + dashboard_subtitle: Бали, правила нарахування та налаштування програми + derived_balance: Сума за журналом + dry_run: Тестовий запуск + dry_run_results: Результати тестового запуску + dry_run_results_card_description: Що нарахували б правила в тестовому режимі + earn_hint_cart: 'За це замовлення нараховується ~%points% балів.' + earn_hint_product: 'Купуючи цей товар, ви отримаєте %points% балів.' + earning_rate_hint: 'Ставку нарахування визначають правила нарахування: базове правило на рівні замовлення з типом «бали за суму» — це стандартна ставка магазину.' + earning_rules: Правила нарахування + earning_rules_card_description: Тригери, умови та суми + edit_earning_rule: Редагувати правило нарахування + edit_program: Редагувати програму + new_earning_rule: Нове правило нарахування + description: Опис + expired: Сплив + expires: 'спливає %date%' + expires_at: Спливає + expiring_soon: '%points% балів спливає до %date%.' + history: Історія транзакцій + history_description: + clawback: 'Бали, повернуті за скасоване або відшкодоване замовлення' + earn_action: 'Нараховані бали' + earn_order: 'Нараховано за замовлення %order%' + earn_referral: 'Винагорода за рекомендацію' + expire: 'Бали спливли' + manual_credit: 'Коригування: %reason%' + manual_debit: 'Коригування: %reason%' + redeem: 'Списано за замовлення %order%' + redeem_rollback: 'Бали відновлено зі скасованого замовлення' + history_empty: Транзакцій поки немає — бали, які ви заробите й витратите, зʼявляться тут. + my_loyalty: Моя лояльність + running_balance: Баланс + inspect: Переглянути + invariants_hold: Усі інваріанти журналу виконуються. + invariants_violated: Інваріанти журналу порушено — розберіться, перш ніж робити ручні виправлення. + ledger: Журнал + lifetime_earned: Зароблено за весь час + lot: Партія + lots: Партії (відтворені з журналу) + loyalty: Лояльність + manual_adjustment: Ручне коригування + manual_reason: + correction: Виправлення + goodwill: Жест доброї волі + other: Інше + promotion: Акція + never: ніколи + note: Примітка + points: Бали + points_amount: '%points% балів' + points_applied: 'Застосовано %points% балів' + points_redemption: Списання балів + program: Програма + program_card_description: Конвертація, сплив і політики для кожного каналу + reason: Причина + refer_a_friend: Порекомендуйте другові + referee: Запрошений + referral_explainer: Поділіться своїм посиланням — коли друг зробить своє перше замовлення, ви обидва отримаєте бали. + referral_override: Схвалити попри все + referral_stats: 'У вас %count% винагороджених рекомендацій, за які нараховано %points% балів.' + referral_status: + expired: Прострочена + pending: Очікує + qualified: Кваліфікована + rejected: Відхилена + rewarded: Винагороджена + referrals: Рекомендації + referrals_card_description: Статуси рекомендацій і ручні схвалення після перевірок на шахрайство + referrer: Рекомендувач + redemption_clamped: 'Ваш запит на %requested% балів застосовано до цього замовлення лише частково; він автоматично збільшиться, щойно замовлення це дозволить.' + remaining: Залишок + remove_redemption: Прибрати + rule: Правило + rule_tester: Тестування правил + rule_tester_card_description: Оцініть правила на нещодавньому замовленні, за бажанням на іншу дату + scope: Область дії + share: Поділитися + copied: Скопійовано! + stat_accounts: Рахунки + stat_active_accounts_90_days: Активні рахунки (90 днів) + stat_liability: 'Непогашене зобов''язання за балами' + stat_redemption_rate_90_days: Частка списання (90 днів) + stat_earned_30_days: Нараховано балів (30 днів) + stat_redeemed_30_days: Списано балів (30 днів) + tester_applied: Застосовано + tester_claimed_basis: Заявлена база + tester_claimed_by: Заявлено правилом + tester_evaluate: Оцінити + tester_evaluate_at: Оцінити так, ніби зараз + tester_evaluate_at_help: Попередній перегляд запланованих правил до відкриття їхнього вікна + tester_failed_conditions: Невиконані умови + tester_final_award: 'Підсумкове нарахування: %points% балів' + tester_invalid_date: Дата оцінювання некоректна. + tester_item_claims: Заявки за позиціями + tester_matched: Збіглося + tester_order_has_no_customer: У замовлення немає клієнта, тому жодне правило не можна оцінити. + tester_order_not_found: Замовлення з таким номером не знайдено. + tester_order_number: Номер замовлення + tester_rules: Оцінювання правил + tier: Рівень + tier_progress: '%metric% / %threshold% до %tier%' + tier_top: Ви досягли найвищого рівня — дякуємо, що ви один із наших найкращих клієнтів! + tiers: Рівні + tiers_card_description: Рівні, пороги, множники та переваги + trigger: Тригер + use_max: Використати максимум + your_code: Ваш код + use_your_points: Використайте свої бали diff --git a/src/Resources/translations/validators.cs.yaml b/src/Resources/translations/validators.cs.yaml new file mode 100644 index 0000000..75a0497 --- /dev/null +++ b/src/Resources/translations/validators.cs.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Váš věrnostní účet je neaktivní, takže uplatněné body nelze využít. Odeberte je prosím z objednávky. + insufficient_balance: Váš zůstatek bodů již nepokrývá uplatněné body. Upravte je prosím v košíku. diff --git a/src/Resources/translations/validators.da.yaml b/src/Resources/translations/validators.da.yaml new file mode 100644 index 0000000..f5189b3 --- /dev/null +++ b/src/Resources/translations/validators.da.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Din loyalitetskonto er inaktiv, så de anvendte point kan ikke indløses. Fjern dem venligst fra ordren. + insufficient_balance: Din pointsaldo dækker ikke længere den anvendte indløsning. Justér den venligst i kurven. diff --git a/src/Resources/translations/validators.de.yaml b/src/Resources/translations/validators.de.yaml new file mode 100644 index 0000000..e4435d6 --- /dev/null +++ b/src/Resources/translations/validators.de.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Ihr Treuekonto ist inaktiv, daher können die angewendeten Punkte nicht eingelöst werden. Bitte entfernen Sie sie aus der Bestellung. + insufficient_balance: Ihr Punktestand deckt die angewendete Einlösung nicht mehr. Bitte passen Sie sie im Warenkorb an. diff --git a/src/Resources/translations/validators.en.yaml b/src/Resources/translations/validators.en.yaml new file mode 100644 index 0000000..3eac165 --- /dev/null +++ b/src/Resources/translations/validators.en.yaml @@ -0,0 +1,4 @@ +setono_sylius_loyalty: + checkout: + account_disabled: Your loyalty account is inactive, so the applied points cannot be redeemed. Please remove them from the order. + insufficient_balance: Your points balance no longer covers the applied redemption. Please adjust it on the cart. diff --git a/src/Resources/translations/validators.es.yaml b/src/Resources/translations/validators.es.yaml new file mode 100644 index 0000000..c138a21 --- /dev/null +++ b/src/Resources/translations/validators.es.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Tu cuenta de fidelidad está inactiva, por lo que los puntos aplicados no se pueden canjear. Quítalos del pedido. + insufficient_balance: Tu saldo de puntos ya no cubre el canje aplicado. Ajústalo en el carrito. diff --git a/src/Resources/translations/validators.fi.yaml b/src/Resources/translations/validators.fi.yaml new file mode 100644 index 0000000..3df43be --- /dev/null +++ b/src/Resources/translations/validators.fi.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Kanta-asiakastilisi ei ole käytössä, joten käytettyjä pisteitä ei voida lunastaa. Poista ne tilauksesta. + insufficient_balance: Pistesaldosi ei enää riitä käytettyyn lunastukseen. Muuta sitä ostoskorissa. diff --git a/src/Resources/translations/validators.fr.yaml b/src/Resources/translations/validators.fr.yaml new file mode 100644 index 0000000..c2df714 --- /dev/null +++ b/src/Resources/translations/validators.fr.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Votre compte de fidélité est inactif, les points appliqués ne peuvent donc pas être utilisés. Veuillez les retirer de la commande. + insufficient_balance: 'Votre solde de points ne couvre plus l''utilisation appliquée. Veuillez l''ajuster sur le panier.' diff --git a/src/Resources/translations/validators.hu.yaml b/src/Resources/translations/validators.hu.yaml new file mode 100644 index 0000000..aa81661 --- /dev/null +++ b/src/Resources/translations/validators.hu.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Hűségfiókja inaktív, ezért a felhasznált pontok nem válthatók be. Kérjük, távolítsa el őket a rendelésből. + insufficient_balance: Pontegyenlege már nem fedezi a felhasznált beváltást. Kérjük, módosítsa a kosárban. diff --git a/src/Resources/translations/validators.it.yaml b/src/Resources/translations/validators.it.yaml new file mode 100644 index 0000000..e853894 --- /dev/null +++ b/src/Resources/translations/validators.it.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: 'Il tuo account fedeltà è inattivo, quindi i punti applicati non possono essere riscattati. Rimuovili dall''ordine.' + insufficient_balance: Il tuo saldo punti non copre più il riscatto applicato. Modificalo nel carrello. diff --git a/src/Resources/translations/validators.nl.yaml b/src/Resources/translations/validators.nl.yaml new file mode 100644 index 0000000..967f4e9 --- /dev/null +++ b/src/Resources/translations/validators.nl.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Je loyaliteitsaccount is inactief, dus de toegepaste punten kunnen niet worden ingewisseld. Verwijder ze van de bestelling. + insufficient_balance: Je puntensaldo dekt de toegepaste inwisseling niet meer. Pas dit aan in de winkelwagen. diff --git a/src/Resources/translations/validators.no.yaml b/src/Resources/translations/validators.no.yaml new file mode 100644 index 0000000..38d3541 --- /dev/null +++ b/src/Resources/translations/validators.no.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Lojalitetskontoen din er inaktiv, så de anvendte pointene kan ikke innløses. Fjern dem fra ordren. + insufficient_balance: Pointsaldoen din dekker ikke lenger den anvendte innløsningen. Juster den i handlekurven. diff --git a/src/Resources/translations/validators.pl.yaml b/src/Resources/translations/validators.pl.yaml new file mode 100644 index 0000000..08319eb --- /dev/null +++ b/src/Resources/translations/validators.pl.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Twoje konto lojalnościowe jest nieaktywne, więc zastosowanych punktów nie można wymienić. Usuń je z zamówienia. + insufficient_balance: Twoje saldo punktów nie pokrywa już zastosowanej wymiany. Skoryguj ją w koszyku. diff --git a/src/Resources/translations/validators.pt.yaml b/src/Resources/translations/validators.pt.yaml new file mode 100644 index 0000000..4fabeee --- /dev/null +++ b/src/Resources/translations/validators.pt.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: A sua conta de fidelidade está inativa, pelo que os pontos aplicados não podem ser resgatados. Remova-os da encomenda. + insufficient_balance: O seu saldo de pontos já não cobre o resgate aplicado. Ajuste-o no carrinho. diff --git a/src/Resources/translations/validators.ro.yaml b/src/Resources/translations/validators.ro.yaml new file mode 100644 index 0000000..09e65f2 --- /dev/null +++ b/src/Resources/translations/validators.ro.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Contul dumneavoastră de fidelitate este inactiv, așa că punctele aplicate nu pot fi utilizate. Vă rugăm să le eliminați din comandă. + insufficient_balance: Soldul dumneavoastră de puncte nu mai acoperă punctele aplicate. Vă rugăm să îl ajustați în coș. diff --git a/src/Resources/translations/validators.sv.yaml b/src/Resources/translations/validators.sv.yaml new file mode 100644 index 0000000..30e20bc --- /dev/null +++ b/src/Resources/translations/validators.sv.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Ditt lojalitetskonto är inaktivt, så de tillämpade poängen kan inte lösas in. Ta bort dem från ordern. + insufficient_balance: Ditt poängsaldo täcker inte längre den tillämpade inlösen. Justera den i varukorgen. diff --git a/src/Resources/translations/validators.uk.yaml b/src/Resources/translations/validators.uk.yaml new file mode 100644 index 0000000..0f406ad --- /dev/null +++ b/src/Resources/translations/validators.uk.yaml @@ -0,0 +1,5 @@ +# Machine-translated from English — pending native review. +setono_sylius_loyalty: + checkout: + account_disabled: Ваш рахунок лояльності неактивний, тому застосовані бали неможливо списати. Будь ласка, приберіть їх із замовлення. + insufficient_balance: Вашого балансу балів більше не вистачає на застосоване списання. Будь ласка, скоригуйте його в кошику. diff --git a/src/Resources/views/admin/account/inspect.html.twig b/src/Resources/views/admin/account/inspect.html.twig new file mode 100644 index 0000000..c477565 --- /dev/null +++ b/src/Resources/views/admin/account/inspect.html.twig @@ -0,0 +1,142 @@ +{% extends '@SyliusAdmin/layout.html.twig' %} + +{% block title %}{{ 'setono_sylius_loyalty.ui.account'|trans }} #{{ account.id }} {{ parent() }}{% endblock %} + +{% block content %} +

+ +
+ {{ account.customer ? account.customer.email : 'setono_sylius_loyalty.ui.anonymized'|trans }} +
+ {{ 'sylius.ui.channel'|trans }}: {{ account.channel ? account.channel.code : '—' }} + {% if account.tier %} + + {{ account.tier.name }} + + {% endif %} + {% if not account.enabled %} + {{ 'sylius.ui.disabled'|trans }} + {% endif %} +
+
+

+ +
+
+
{{ account.balance }}
+
{{ 'setono_sylius_loyalty.ui.balance_label'|trans }}
+
+
+
{{ account.lifetimeEarned }}
+
{{ 'setono_sylius_loyalty.ui.lifetime_earned'|trans }}
+
+
+
{{ inspection.replay.balance }}
+
{{ 'setono_sylius_loyalty.ui.derived_balance'|trans }}
+
+
+ + {% if inspection.healthy %} +
{{ 'setono_sylius_loyalty.ui.invariants_hold'|trans }}
+ {% else %} +
+
{{ 'setono_sylius_loyalty.ui.invariants_violated'|trans }}
+
    + {% for error in inspection.errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + {% for warning in inspection.warnings %} +
{{ warning }}
+ {% endfor %} + +
+
+

{{ 'setono_sylius_loyalty.ui.manual_adjustment'|trans }}

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

{{ 'sylius.ui.actions'|trans }}

+
+ + +
+
+
+ +

{{ 'setono_sylius_loyalty.ui.lots'|trans }}

+ + + + + + + + + + + + {% for lotState in inspection.replay.lots %} + + + + + + + + {% endfor %} + +
{{ 'setono_sylius_loyalty.ui.lot'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}{{ 'setono_sylius_loyalty.ui.expires_at'|trans }}{{ 'setono_sylius_loyalty.ui.remaining'|trans }}{{ 'setono_sylius_loyalty.ui.consumptions'|trans }}
#{{ lotState.lot.id }}{{ lotState.lot.points }} + {{ lotState.lot.expiresAt ? lotState.lot.expiresAt|date('Y-m-d') : 'setono_sylius_loyalty.ui.never'|trans }} + {% if lotState.closedByExpiration %}{{ 'setono_sylius_loyalty.ui.expired'|trans }}{% endif %} + {{ lotState.remaining }} + {% for consumption in lotState.consumptions %} + #{{ consumption.debit.id }}: −{{ consumption.points }} + {% endfor %} +
+ +

{{ 'setono_sylius_loyalty.ui.ledger'|trans }}

+ + + + + + + + + + + {% for transaction in transactions %} + + + + + + + {% endfor %} + +
{{ 'sylius.ui.id'|trans }}{{ 'sylius.ui.type'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}{{ 'sylius.ui.date'|trans }}
#{{ transaction.id }}{{ setono_sylius_loyalty_transaction_type(transaction) }}{{ transaction.points }}{{ transaction.occurredAt|date('Y-m-d H:i:s') }}
+{% endblock %} diff --git a/src/Resources/views/admin/customer/_loyalty.html.twig b/src/Resources/views/admin/customer/_loyalty.html.twig new file mode 100644 index 0000000..b4cec79 --- /dev/null +++ b/src/Resources/views/admin/customer/_loyalty.html.twig @@ -0,0 +1,49 @@ +{% set accounts = setono_sylius_loyalty_accounts(customer) %} + +{% if accounts is not empty %} +
+

{{ 'setono_sylius_loyalty.ui.loyalty'|trans }}

+ + {% for account in accounts %} +
+
+
+
{{ account.balance }}
+
+ {{ 'setono_sylius_loyalty.ui.balance_label'|trans }} + ({{ account.channel ? account.channel.code : '—' }}) + {% if not account.enabled %} + {{ 'sylius.ui.disabled'|trans }} + {% endif %} +
+
+ +
+
+ + + + + + + + + + {% for transaction in setono_sylius_loyalty_latest_transactions(account, 25) %} + + + + + + {% endfor %} + +
{{ 'sylius.ui.type'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}{{ 'sylius.ui.date'|trans }}
{{ setono_sylius_loyalty_transaction_type(transaction) }}{{ transaction.points }}{{ transaction.occurredAt|date('Y-m-d H:i') }}
+
+
+ {% endfor %} +
+{% endif %} diff --git a/src/Resources/views/admin/dashboard/index.html.twig b/src/Resources/views/admin/dashboard/index.html.twig new file mode 100644 index 0000000..c37f5c2 --- /dev/null +++ b/src/Resources/views/admin/dashboard/index.html.twig @@ -0,0 +1,93 @@ +{% extends '@SyliusAdmin/layout.html.twig' %} + +{% block title %}{{ 'setono_sylius_loyalty.ui.loyalty'|trans }} {{ parent() }}{% endblock %} + +{% block content %} +

+ +
+ {{ 'setono_sylius_loyalty.ui.loyalty'|trans }} +
{{ 'setono_sylius_loyalty.ui.dashboard_subtitle'|trans }}
+
+

+ +
+
+
{{ stats.accounts }}
+
{{ 'setono_sylius_loyalty.ui.stat_accounts'|trans }}
+
+
+
{{ stats.earnedLast30Days }}
+
{{ 'setono_sylius_loyalty.ui.stat_earned_30_days'|trans }}
+
+
+
{{ stats.redeemedLast30Days }}
+
{{ 'setono_sylius_loyalty.ui.stat_redeemed_30_days'|trans }}
+
+
+ +
+
+
{{ stats.liabilityPoints ?? '—' }}
+
+ {{ 'setono_sylius_loyalty.ui.stat_liability'|trans }} + {% if stats.liabilityCalculatedAt %} + ({{ stats.liabilityCalculatedAt|date('Y-m-d H:i') }}) + {% endif %} +
+
+
+
{{ stats.redemptionRate90Days is not null ? stats.redemptionRate90Days ~ '%' : '—' }}
+
{{ 'setono_sylius_loyalty.ui.stat_redemption_rate_90_days'|trans }}
+
+
+
{{ stats.activeAccounts90Days }}
+
{{ 'setono_sylius_loyalty.ui.stat_active_accounts_90_days'|trans }}
+
+
+ + +{% endblock %} diff --git a/src/Resources/views/admin/grid/field/tier.html.twig b/src/Resources/views/admin/grid/field/tier.html.twig new file mode 100644 index 0000000..b489e63 --- /dev/null +++ b/src/Resources/views/admin/grid/field/tier.html.twig @@ -0,0 +1,3 @@ +{% if data %} + {{ data.name }} +{% endif %} diff --git a/src/Resources/views/admin/program/index.html.twig b/src/Resources/views/admin/program/index.html.twig new file mode 100644 index 0000000..d8ac1ad --- /dev/null +++ b/src/Resources/views/admin/program/index.html.twig @@ -0,0 +1,46 @@ +{% extends '@SyliusAdmin/layout.html.twig' %} + +{% block title %}{{ 'setono_sylius_loyalty.ui.program'|trans }} {{ parent() }}{% endblock %} + +{% block content %} +

+ +
+ {{ 'setono_sylius_loyalty.ui.program'|trans }} +
{{ 'setono_sylius_loyalty.ui.program_card_description'|trans }}
+
+

+ + + + + + + + + + + + + {% for row in programs %} + + + + + + + + {% endfor %} + +
{{ 'sylius.ui.channel'|trans }}{{ 'setono_sylius_loyalty.form.program.award_order_points_at'|trans }}{{ 'setono_sylius_loyalty.form.program.redemption_conversion_points'|trans }}{{ 'setono_sylius_loyalty.form.program.points_expiry_days'|trans }}{{ 'sylius.ui.actions'|trans }}
{{ row.channel.name }} ({{ row.channel.code }}){{ row.program.awardOrderPointsAt }}{{ row.program.redemptionConversionPoints }} = {{ row.program.redemptionConversionAmount }}{{ row.program.pointsExpiryDays ?? 'setono_sylius_loyalty.ui.never'|trans }} + + + {{ 'sylius.ui.edit'|trans }} + +
+ +
+ {{ 'setono_sylius_loyalty.ui.earning_rate_hint'|trans }} + {{ 'setono_sylius_loyalty.ui.earning_rules'|trans }} +
+{% endblock %} diff --git a/src/Resources/views/admin/referral/_override.html.twig b/src/Resources/views/admin/referral/_override.html.twig new file mode 100644 index 0000000..9ed11bd --- /dev/null +++ b/src/Resources/views/admin/referral/_override.html.twig @@ -0,0 +1,10 @@ +{% if data.status == 'rejected' %} +
+ + +
+{% elseif data.status == 'rejected' or data.fraudFlags is not empty %} + {{ data.fraudFlags|map(f => f.check)|join(', ') }} +{% endif %} diff --git a/src/Resources/views/admin/rule_tester/index.html.twig b/src/Resources/views/admin/rule_tester/index.html.twig new file mode 100644 index 0000000..8b80037 --- /dev/null +++ b/src/Resources/views/admin/rule_tester/index.html.twig @@ -0,0 +1,117 @@ +{% extends '@SyliusAdmin/layout.html.twig' %} + +{% block title %}{{ 'setono_sylius_loyalty.ui.rule_tester'|trans }} {{ parent() }}{% endblock %} + +{% block content %} +

+ +
+ {{ 'setono_sylius_loyalty.ui.rule_tester'|trans }} +
{{ 'setono_sylius_loyalty.ui.rule_tester_card_description'|trans }}
+
+

+ +
+
+
+ + +
+
+ + + {{ 'setono_sylius_loyalty.ui.tester_evaluate_at_help'|trans }} +
+
+ + +
+
+
+ + {% if error is not null %} +
{{ error|trans }}
+ {% endif %} + + {% if result is not null %} +
+
+ {{ 'setono_sylius_loyalty.ui.tester_final_award'|trans({'%points%': result.points}) }} +
+
+ +

{{ 'setono_sylius_loyalty.ui.tester_rules'|trans }}

+ + + + + + + + + + + + + {% for evaluation in result.ruleEvaluations %} + + + + + + + + + {% endfor %} + +
{{ 'setono_sylius_loyalty.ui.rule'|trans }}{{ 'setono_sylius_loyalty.ui.tester_matched'|trans }}{{ 'setono_sylius_loyalty.ui.tester_failed_conditions'|trans }}{{ 'setono_sylius_loyalty.ui.tester_claimed_basis'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}{{ 'setono_sylius_loyalty.ui.tester_applied'|trans }}
{{ evaluation.rule.name }}{% if evaluation.factor is not null %} ×{{ evaluation.factor }}{% endif %}{% if evaluation.matched %}{% else %}{% endif %}{{ evaluation.failedConditions|join(', ') }}{{ evaluation.claimedBasis }}{{ evaluation.points }}{% if evaluation.applied %}{% else %}{% endif %}
+ + {% if result.dryRunEvaluations is not empty %} +

{{ 'setono_sylius_loyalty.ui.dry_run_results'|trans }}

+ + + + + + + + + + {% for evaluation in result.dryRunEvaluations %} + + + + + + {% endfor %} + +
{{ 'setono_sylius_loyalty.ui.rule'|trans }}{{ 'setono_sylius_loyalty.ui.tester_matched'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}
{{ evaluation.rule.name }}{% if evaluation.matched %}{% else %}{% endif %}{{ evaluation.points }}
+ {% endif %} + + {% if order is not null %} +

{{ 'setono_sylius_loyalty.ui.tester_item_claims'|trans }}

+ + + + + + + + + {% for item in order.items %} + + + + + {% endfor %} + +
{{ 'sylius.ui.item'|trans }}{{ 'setono_sylius_loyalty.ui.tester_claimed_by'|trans }}
{{ item.productName }} (#{{ item.id }}) + {% for evaluation in result.ruleEvaluations %} + {% if evaluation.applied and evaluation.claimedItems[item.id] is defined %} + {{ evaluation.rule.name }}: {{ evaluation.claimedItems[item.id] }} + {% endif %} + {% endfor %} +
+ {% endif %} + {% endif %} +{% endblock %} diff --git a/src/Resources/views/form/theme.html.twig b/src/Resources/views/form/theme.html.twig new file mode 100644 index 0000000..c9c7469 --- /dev/null +++ b/src/Resources/views/form/theme.html.twig @@ -0,0 +1,4 @@ +{% block setono_sylius_loyalty_expression_widget %} + {{ block('textarea_widget') }} + +{% endblock %} diff --git a/src/Resources/views/shop/account/loyalty/_referral.html.twig b/src/Resources/views/shop/account/loyalty/_referral.html.twig new file mode 100644 index 0000000..22ec1ac --- /dev/null +++ b/src/Resources/views/shop/account/loyalty/_referral.html.twig @@ -0,0 +1,38 @@ +{% if referral is not null %} +

{{ 'setono_sylius_loyalty.ui.refer_a_friend'|trans }}

+
+

{{ 'setono_sylius_loyalty.ui.referral_explainer'|trans }}

+ +
+ + +
+

+ {{ 'setono_sylius_loyalty.ui.your_code'|trans }}: {{ referral.code }} +

+ + {% if referral.stats.rewarded > 0 %} +

+ {{ 'setono_sylius_loyalty.ui.referral_stats'|trans({'%count%': referral.stats.rewarded, '%points%': referral.stats.pointsEarned}) }} +

+ {% endif %} + + +
+{% endif %} diff --git a/src/Resources/views/shop/account/loyalty/index.html.twig b/src/Resources/views/shop/account/loyalty/index.html.twig new file mode 100644 index 0000000..4456294 --- /dev/null +++ b/src/Resources/views/shop/account/loyalty/index.html.twig @@ -0,0 +1,115 @@ +{% extends '@SyliusShop/Account/layout.html.twig' %} + +{% block title %}{{ 'setono_sylius_loyalty.ui.my_loyalty'|trans }} | {{ parent() }}{% endblock %} + +{% block subcontent %} +

+ +
+ {{ 'setono_sylius_loyalty.ui.my_loyalty'|trans }} +
+

+ + {# Balance hero #} +
+
+
{{ account ? account.balance : 0 }}
+
{{ 'setono_sylius_loyalty.ui.points'|trans }}
+
+ {% if tierProgress is not null %} +
+ {% if tierProgress.current %} + + {{ tierProgress.current.name }} + + {% if tierProgress.current.benefitsDescription %} +

{{ tierProgress.current.benefitsDescription }}

+ {% endif %} + {% endif %} + + {% if tierProgress.topTier %} +
+ + {{ 'setono_sylius_loyalty.ui.tier_top'|trans }} +
+ {% elseif tierProgress.next %} +
+
+
+
+ + {{ 'setono_sylius_loyalty.ui.tier_progress'|trans({'%metric%': tierProgress.metric, '%threshold%': tierProgress.threshold, '%tier%': tierProgress.next.name}) }} + +
+ {% endif %} +
+ {% endif %} + {% if account is not null and not account.enabled %} +
+ {{ 'setono_sylius_loyalty.ui.account_inactive'|trans }} +
+ {% endif %} +
+ + {# Expiring-soon callout #} + {% if expiringSoon is not null %} +
+ + {{ 'setono_sylius_loyalty.ui.expiring_soon'|trans({'%points%': expiringSoon.points, '%date%': expiringSoon.until|date('Y-m-d')}) }} +
+ {% endif %} + + {% include '@SetonoSyliusLoyaltyPlugin/shop/account/loyalty/_referral.html.twig' %} + + {# Transaction history #} +

{{ 'setono_sylius_loyalty.ui.history'|trans }}

+ {% if rows is empty %} +

{{ 'setono_sylius_loyalty.ui.history_empty'|trans }}

+ {% else %} + + + + + + + + + + + {% for row in rows %} + {% set transaction = row.transaction %} + {% set type = setono_sylius_loyalty_transaction_type(transaction) %} + + + + + + + {% endfor %} + +
{{ 'sylius.ui.date'|trans }}{{ 'setono_sylius_loyalty.ui.description'|trans }}{{ 'setono_sylius_loyalty.ui.points'|trans }}{{ 'setono_sylius_loyalty.ui.running_balance'|trans }}
{{ transaction.occurredAt|date('Y-m-d') }} + {% if type in ['earn_order', 'redeem'] and transaction.order is not null %} + {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans({'%order%': transaction.order.number}) }} + {% elseif type in ['manual_credit', 'manual_debit'] %} + {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans({'%reason%': ('setono_sylius_loyalty.ui.manual_reason.' ~ transaction.reason)|trans}) }} + {% else %} + {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans }} + {% endif %} + {% if transaction.points > 0 and transaction.expiresAt is defined and transaction.expiresAt is not null %} + + · {{ 'setono_sylius_loyalty.ui.expires'|trans({'%date%': transaction.expiresAt|date('Y-m-d')}) }} + + {% endif %} + + {{ transaction.points > 0 ? '+' : '' }}{{ transaction.points }} + {{ row.runningBalance }}
+ + {% if pages > 1 %} + + {% endif %} + {% endif %} +{% endblock %} diff --git a/src/Resources/views/shop/cart/_earn_hint.html.twig b/src/Resources/views/shop/cart/_earn_hint.html.twig new file mode 100644 index 0000000..4af8b70 --- /dev/null +++ b/src/Resources/views/shop/cart/_earn_hint.html.twig @@ -0,0 +1,8 @@ +{% set points = setono_sylius_loyalty_cart_earn_hint(cart) %} + +{% if points is not null %} +
+ + {{ 'setono_sylius_loyalty.ui.earn_hint_cart'|trans({'%points%': points}) }} +
+{% endif %} diff --git a/src/Resources/views/shop/cart/_redemption.html.twig b/src/Resources/views/shop/cart/_redemption.html.twig new file mode 100644 index 0000000..6e247d3 --- /dev/null +++ b/src/Resources/views/shop/cart/_redemption.html.twig @@ -0,0 +1,55 @@ +{% import '@SyliusShop/Common/Macro/money.html.twig' as money %} + +{% set redemption = cart is defined ? setono_sylius_loyalty_cart_redemption(cart) : null %} + +{% if redemption is not null %} +
+

{{ 'setono_sylius_loyalty.ui.use_your_points'|trans }}

+ +

+ {{ 'setono_sylius_loyalty.ui.balance'|trans }}: + {{ redemption.balance }} +

+ + {% if redemption.appliedPoints > 0 %} +
+ {{ 'setono_sylius_loyalty.ui.points_applied'|trans({'%points%': redemption.appliedPoints}) }} + ({{ '−' }}{{ money.convertAndFormat(redemption.appliedAmount) }}) +
+ + {% if redemption.clamped %} +
+ {{ 'setono_sylius_loyalty.ui.redemption_clamped'|trans({'%requested%': redemption.requestedPoints}) }} +
+ {% endif %} + +
+ + +
+ {% endif %} + + {% if redemption.canRedeem %} +
+ + + {% if redemption.presets is not empty %} +
+ {% for preset in redemption.presets %} + + {% endfor %} +
+ {% endif %} + + +
+ {% endif %} +
+{% endif %} diff --git a/src/Resources/views/shop/checkout/_redemption_summary.html.twig b/src/Resources/views/shop/checkout/_redemption_summary.html.twig new file mode 100644 index 0000000..482d2ae --- /dev/null +++ b/src/Resources/views/shop/checkout/_redemption_summary.html.twig @@ -0,0 +1,12 @@ +{% import '@SyliusShop/Common/Macro/money.html.twig' as money %} + +{% set redemption = order is defined ? setono_sylius_loyalty_cart_redemption(order) : null %} + +{% if redemption is not null and redemption.appliedPoints > 0 %} +
+ {{ 'setono_sylius_loyalty.ui.points_applied'|trans({'%points%': redemption.appliedPoints}) }} + ({{ '−' }}{{ money.convertAndFormat(redemption.appliedAmount) }}) + — + {{ 'setono_sylius_loyalty.ui.change_on_cart'|trans }} +
+{% endif %} diff --git a/src/Resources/views/shop/product/_earn_hint.html.twig b/src/Resources/views/shop/product/_earn_hint.html.twig new file mode 100644 index 0000000..48dddda --- /dev/null +++ b/src/Resources/views/shop/product/_earn_hint.html.twig @@ -0,0 +1,24 @@ +{% set hint = setono_sylius_loyalty_product_earn_hint(product) %} + +{% if hint is not null %} +
+ + {{ 'setono_sylius_loyalty.ui.earn_hint_product'|trans({'%points%': hint.default}) }} + + {# One entry per variant, keyed both by variant code and by option-value data + attributes so the map works for choice- and option-based selection alike #} + +
+ +{% endif %} diff --git a/src/SetonoSyliusLoyaltyPlugin.php b/src/SetonoSyliusLoyaltyPlugin.php index 45b9c9b..a305b05 100644 --- a/src/SetonoSyliusLoyaltyPlugin.php +++ b/src/SetonoSyliusLoyaltyPlugin.php @@ -4,10 +4,58 @@ namespace Setono\SyliusLoyaltyPlugin; +use Setono\CompositeCompilerPass\CompositeCompilerPass; +use Setono\SyliusLoyaltyPlugin\DependencyInjection\Compiler\RegisterEarningTriggersPass; +use Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculatorRegistry; +use Setono\SyliusLoyaltyPlugin\EarningRule\Checker\ConditionCheckerRegistry; +use Setono\SyliusLoyaltyPlugin\Expression\Function\ExpressionFunctionRegistry; +use Setono\SyliusLoyaltyPlugin\Referral\FraudCheck\CompositeReferralFraudCheck; +use Setono\SyliusLoyaltyPlugin\Tier\QualificationBasis\TierQualificationBasisRegistry; use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use Sylius\Bundle\ResourceBundle\AbstractResourceBundle; +use Sylius\Bundle\ResourceBundle\SyliusResourceBundle; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; +use Symfony\Component\DependencyInjection\ContainerBuilder; -final class SetonoSyliusLoyaltyPlugin extends Bundle +final class SetonoSyliusLoyaltyPlugin extends AbstractResourceBundle { use SyliusPluginTrait; + + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new CompositeCompilerPass( + ConditionCheckerRegistry::class, + 'setono_sylius_loyalty.earning_condition', + )); + $container->addCompilerPass(new CompositeCompilerPass( + AmountCalculatorRegistry::class, + 'setono_sylius_loyalty.earning_amount', + )); + $container->addCompilerPass(new CompositeCompilerPass( + ExpressionFunctionRegistry::class, + 'setono_sylius_loyalty.expression_function', + )); + $container->addCompilerPass(new CompositeCompilerPass( + TierQualificationBasisRegistry::class, + 'setono_sylius_loyalty.tier_qualification_basis', + )); + $container->addCompilerPass(new CompositeCompilerPass( + CompositeReferralFraudCheck::class, + 'setono_sylius_loyalty.referral_fraud_check', + )); + + // Must run before Symfony's RegisterListenersPass (same phase, priority 0) so the + // trigger listener tags it adds are picked up + $container->addCompilerPass(new RegisterEarningTriggersPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + } + + /** + * @return list + */ + public function getSupportedDrivers(): array + { + return [SyliusResourceBundle::DRIVER_DOCTRINE_ORM]; + } } diff --git a/src/Tier/EvaluationWindowResolver.php b/src/Tier/EvaluationWindowResolver.php new file mode 100644 index 0000000..dda0201 --- /dev/null +++ b/src/Tier/EvaluationWindowResolver.php @@ -0,0 +1,25 @@ +getTierEvaluationWindow()) { + LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_LIFETIME => null, + LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_CALENDAR_YEAR => new DateRange( + $now->modify('first day of january this year')->setTime(0, 0), + $now->modify('first day of january next year')->setTime(0, 0), + ), + default => new DateRange($now->modify('-12 months'), $now), + }; + } +} diff --git a/src/Tier/EvaluationWindowResolverInterface.php b/src/Tier/EvaluationWindowResolverInterface.php new file mode 100644 index 0000000..ebc9b7c --- /dev/null +++ b/src/Tier/EvaluationWindowResolverInterface.php @@ -0,0 +1,16 @@ +entityManager->createQueryBuilder() + ->select('COALESCE(SUM(o.total), 0)') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.channel = :channel') + ->andWhere('o.paymentState = :paymentState') + ->setParameter('customer', $account->getCustomer()) + ->setParameter('channel', $account->getChannel()) + ->setParameter('paymentState', OrderPaymentStates::STATE_PAID) + ; + + if (null !== $window) { + $qb->andWhere('o.checkoutCompletedAt >= :start')->setParameter('start', $window->start); + $qb->andWhere('o.checkoutCompletedAt <= :end')->setParameter('end', $window->end); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/src/Tier/QualificationBasis/DateRange.php b/src/Tier/QualificationBasis/DateRange.php new file mode 100644 index 0000000..43216aa --- /dev/null +++ b/src/Tier/QualificationBasis/DateRange.php @@ -0,0 +1,14 @@ +entityManager->createQueryBuilder() + ->select('COUNT(o.id)') + ->from($this->orderClass, 'o') + ->andWhere('o.customer = :customer') + ->andWhere('o.channel = :channel') + ->andWhere('o.paymentState = :paymentState') + ->setParameter('customer', $account->getCustomer()) + ->setParameter('channel', $account->getChannel()) + ->setParameter('paymentState', OrderPaymentStates::STATE_PAID) + ; + + if (null !== $window) { + $qb->andWhere('o.checkoutCompletedAt >= :start')->setParameter('start', $window->start); + $qb->andWhere('o.checkoutCompletedAt <= :end')->setParameter('end', $window->end); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/src/Tier/QualificationBasis/PointsEarnedBasis.php b/src/Tier/QualificationBasis/PointsEarnedBasis.php new file mode 100644 index 0000000..e0384f3 --- /dev/null +++ b/src/Tier/QualificationBasis/PointsEarnedBasis.php @@ -0,0 +1,62 @@ +entityManager->createQueryBuilder() + ->select('COALESCE(SUM(t.points), 0)') + ->from($this->transactionClass, 't') + ->andWhere('t.account = :account') + ->andWhere('t.points > 0') + ->andWhere('t NOT INSTANCE OF :rollback') + ->setParameter('account', $account) + ->setParameter('rollback', $this->entityManager->getClassMetadata(RedeemRollbackLoyaltyTransaction::class)) + ; + + if (null !== $window) { + $qb->andWhere('t.occurredAt >= :start')->setParameter('start', $window->start); + $qb->andWhere('t.occurredAt <= :end')->setParameter('end', $window->end); + } + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/src/Tier/QualificationBasis/TierQualificationBasisInterface.php b/src/Tier/QualificationBasis/TierQualificationBasisInterface.php new file mode 100644 index 0000000..a5e8c41 --- /dev/null +++ b/src/Tier/QualificationBasis/TierQualificationBasisInterface.php @@ -0,0 +1,35 @@ + + */ +final class TierQualificationBasisRegistry extends CompositeService implements TierQualificationBasisRegistryInterface +{ + public function get(string $code): TierQualificationBasisInterface + { + $available = []; + foreach ($this->services as $basis) { + if ($basis->getCode() === $code) { + return $basis; + } + $available[] = $basis->getCode(); + } + + throw InvalidTierQualificationBasisException::unknown($code, $available); + } + + public function all(): array + { + return $this->services; + } +} diff --git a/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php b/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php new file mode 100644 index 0000000..f1266bc --- /dev/null +++ b/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php @@ -0,0 +1,18 @@ + + */ + public function all(): array; +} diff --git a/src/Tier/TierEvaluator.php b/src/Tier/TierEvaluator.php new file mode 100644 index 0000000..93a71c5 --- /dev/null +++ b/src/Tier/TierEvaluator.php @@ -0,0 +1,135 @@ +qualifiedTier($account); + if (null === $qualified) { + return; + } + + $current = $account->getTier(); + if (null !== $current && $qualified->getPosition() <= $current->getPosition()) { + return; + } + + $this->change($account, $qualified); + $account->setTierBelowThresholdSince(null); + } + + /** + * Nightly reconciliation: upgrades apply, downgrades apply immediately or after the + * program's grace period, counted from when the account first evaluated below its tier. + */ + public function reconcile(LoyaltyAccountInterface $account, \DateTimeImmutable $now): void + { + $qualified = $this->qualifiedTier($account); + $current = $account->getTier(); + + $qualifiedPosition = $qualified?->getPosition() ?? \PHP_INT_MIN; + $currentPosition = null === $current ? \PHP_INT_MIN : $current->getPosition(); + + if ($qualifiedPosition === $currentPosition) { + $account->setTierBelowThresholdSince(null); + + return; + } + + if ($qualifiedPosition > $currentPosition) { + $this->change($account, $qualified); + $account->setTierBelowThresholdSince(null); + + return; + } + + // Below the current tier: start or continue the grace clock + $channel = $account->getChannel(); + \assert($channel instanceof ChannelInterface); + $graceDays = $this->programProvider->getByChannel($channel)->getTierDowngradeGraceDays(); + + $belowSince = $account->getTierBelowThresholdSince(); + if (null === $belowSince) { + $account->setTierBelowThresholdSince($now); + $belowSince = $now; + } + + if ($belowSince->modify(sprintf('+%d days', $graceDays)) <= $now) { + $this->change($account, $qualified); + $account->setTierBelowThresholdSince(null); + } + } + + /** + * The highest enabled tier whose threshold the account meets on that tier's own basis. + */ + private function qualifiedTier(LoyaltyAccountInterface $account): ?TierInterface + { + $channel = $account->getChannel(); + if (!$channel instanceof ChannelInterface) { + return null; + } + + $tiers = $this->tierRepository->findQualifiable($channel); + if ([] === $tiers) { + return null; + } + + $window = $this->windowResolver->resolve($this->programProvider->getByChannel($channel)); + + /** @var array $metrics */ + $metrics = []; + foreach ($tiers as $tier) { + $basisCode = $tier->getQualificationBasis(); + $metrics[$basisCode] ??= $this->basisRegistry->get($basisCode)->calculate($account, $window); + + if ($metrics[$basisCode] >= $tier->getThreshold()) { + return $tier; + } + } + + return null; + } + + private function change(LoyaltyAccountInterface $account, ?TierInterface $to): void + { + $from = $account->getTier(); + + $changing = new TierChanging($account, $from, $to); + $this->eventDispatcher->dispatch($changing); + if ($changing->isCancelled()) { + return; + } + + $account->setTier($to); + + $this->eventDispatcher->dispatch(new TierChanged($account, $from, $to)); + } +} diff --git a/src/Tier/TierEvaluatorInterface.php b/src/Tier/TierEvaluatorInterface.php new file mode 100644 index 0000000..7edf4ed --- /dev/null +++ b/src/Tier/TierEvaluatorInterface.php @@ -0,0 +1,25 @@ + $accountClass + */ + public function __construct( + private readonly CartRedemptionViewProviderInterface $cartRedemptionViewProvider, + private readonly LoyaltyTransactionRepositoryInterface $transactionRepository, + private readonly EntityManagerInterface $entityManager, + private readonly EarnHintCalculatorInterface $earnHintCalculator, + private readonly LoyaltyProgramProviderInterface $programProvider, + private readonly ChannelContextInterface $channelContext, + private readonly CustomerContextInterface $customerContext, + private readonly string $accountClass, + ) { + } + + public function cartRedemption(OrderInterface $cart): ?CartRedemptionView + { + return $this->cartRedemptionViewProvider->getView($cart); + } + + /** + * The transaction's discriminator value (e.g. "earn_order", "redeem"). + */ + public function transactionType(LoyaltyTransactionInterface $transaction): string + { + $discriminator = $this->entityManager->getClassMetadata($transaction::class)->discriminatorValue; + + return is_string($discriminator) ? $discriminator : $transaction::class; + } + + /** + * @return list + */ + public function accountsOf(CustomerInterface $customer): array + { + /** @var list $accounts */ + $accounts = $this->entityManager->getRepository($this->accountClass)->findBy(['customer' => $customer]); + + return $accounts; + } + + /** + * The account's latest transactions, newest first. + * + * @return list + */ + public function latestTransactions(LoyaltyAccountInterface $account, int $limit = 25): array + { + return array_slice(array_reverse($this->transactionRepository->findForReplay($account)), 0, $limit); + } + + /** + * Per-variant earn hint for the product page, or null when hidden (toggle off, no + * applicable rules, disabled account). + * + * @return array{default: int, variants: array}|null + */ + public function productEarnHint(ProductInterface $product): ?array + { + $channel = $this->channelContext->getChannel(); + if (!$channel instanceof ChannelInterface || !$this->programProvider->getByChannel($channel)->isShowEarnableOnProduct()) { + return null; + } + + $customer = $this->customerContext->getCustomer(); + $customer = $customer instanceof CustomerInterface ? $customer : null; + + $variants = []; + $default = null; + foreach ($product->getEnabledVariants() as $variant) { + \assert($variant instanceof ProductVariantInterface); + $points = $this->earnHintCalculator->forVariant($variant, $channel, $customer); + $variants[(string) $variant->getCode()] = $points; + $default ??= $points; + } + + return null === $default ? null : ['default' => $default, 'variants' => $variants]; + } + + /** + * "This order earns ~N points", or null when hidden. + */ + public function cartEarnHint(OrderInterface $cart): ?int + { + $channel = $cart->getChannel(); + if (!$channel instanceof ChannelInterface || !$this->programProvider->getByChannel($channel)->isShowEarnableInCart()) { + return null; + } + + $customer = $cart->getCustomer(); + + return $this->earnHintCalculator->forCart($cart, $customer instanceof CustomerInterface ? $customer : null); + } +} diff --git a/src/Validator/Constraints/LoyaltyRedemptionValid.php b/src/Validator/Constraints/LoyaltyRedemptionValid.php new file mode 100644 index 0000000..c0685fa --- /dev/null +++ b/src/Validator/Constraints/LoyaltyRedemptionValid.php @@ -0,0 +1,30 @@ +appliedPointsProvider->getAppliedPoints($value); + if ($appliedPoints <= 0) { + return; + } + + $customer = $value->getCustomer(); + $channel = $value->getChannel(); + if (!$customer instanceof CustomerInterface || null === $channel) { + return; + } + + $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel); + + if (null === $account || !$account->isEnabled()) { + $this->context->buildViolation($constraint->accountDisabledMessage)->addViolation(); + + return; + } + + if ($account->getBalance() < $appliedPoints) { + $this->context->buildViolation($constraint->insufficientBalanceMessage)->addViolation(); + } + } +} diff --git a/tests/Application/.env b/tests/Application/.env index f4370e8..5ee199a 100644 --- a/tests/Application/.env +++ b/tests/Application/.env @@ -23,13 +23,13 @@ APP_SECRET=EDITME # Format described at https://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # For a sqlite database, use: "sqlite:///%kernel.project_dir%/var/data.db" # Set "serverVersion" to your server version to avoid edge-case exceptions and extra database calls -DATABASE_URL=mysql://root@127.0.0.1/acme_sylius_skeleton_%kernel.environment%?serverVersion=11.6.2-MariaDB +DATABASE_URL=mysql://root@127.0.0.1/setono_sylius_loyalty_%kernel.environment%?serverVersion=11.6.2-MariaDB ###< doctrine/doctrine-bundle ### ###> lexik/jwt-authentication-bundle ### JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem -JWT_PASSPHRASE=acme_plugin_development +JWT_PASSPHRASE=setono_sylius_loyalty_development ###< lexik/jwt-authentication-bundle ### ###> symfony/messenger ### diff --git a/tests/Application/Entity/Order.php b/tests/Application/Entity/Order.php new file mode 100644 index 0000000..ebec709 --- /dev/null +++ b/tests/Application/Entity/Order.php @@ -0,0 +1,17 @@ + ['all' => true], League\FlysystemBundle\FlysystemBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/tests/Application/config/packages/doctrine.yaml b/tests/Application/config/packages/doctrine.yaml index f51ba5a..1b33c51 100644 --- a/tests/Application/config/packages/doctrine.yaml +++ b/tests/Application/config/packages/doctrine.yaml @@ -12,3 +12,11 @@ doctrine: charset: UTF8 url: '%env(resolve:DATABASE_URL)%' + + orm: + mappings: + TestApplication: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/Entity' + prefix: 'Setono\SyliusLoyaltyPlugin\Tests\Application\Entity' diff --git a/tests/Application/config/packages/messenger.yaml b/tests/Application/config/packages/messenger.yaml new file mode 100644 index 0000000..524ea05 --- /dev/null +++ b/tests/Application/config/packages/messenger.yaml @@ -0,0 +1,6 @@ +# The loyalty_async transport exists for the async awarding functional test: nothing routes +# to it by default (awarding stays sync), the test targets it with a TransportNamesStamp. +framework: + messenger: + transports: + loyalty_async: 'doctrine://default?queue_name=loyalty_test' diff --git a/tests/Application/config/packages/setono_sylius_loyalty.yaml b/tests/Application/config/packages/setono_sylius_loyalty.yaml index 78e8885..5bea7d3 100644 --- a/tests/Application/config/packages/setono_sylius_loyalty.yaml +++ b/tests/Application/config/packages/setono_sylius_loyalty.yaml @@ -1,2 +1,4 @@ setono_sylius_loyalty: - option: Option value + expression_editor: + # Vendored snapshot so CI never depends on CDN reachability + cdn_base_url: '/vendor/codemirror' diff --git a/tests/Application/config/packages/sylius_fixtures_loyalty.yaml b/tests/Application/config/packages/sylius_fixtures_loyalty.yaml new file mode 100644 index 0000000..bf413b1 --- /dev/null +++ b/tests/Application/config/packages/sylius_fixtures_loyalty.yaml @@ -0,0 +1,114 @@ +# The deterministic loyalty seed for functional and e2e tests. Loaded on top of the default +# suite (no purge listeners): sylius:fixtures:load default && sylius:fixtures:load loyalty +sylius_fixtures: + suites: + loyalty: + fixtures: + setono_sylius_loyalty_program: + options: + custom: + - channel: FASHION_WEB + min_redeem_points: 500 + max_redeem_percent_of_order: 50 + points_expiry_days: 365 + + setono_sylius_loyalty_tier: + options: + custom: + - code: silver + name: Silver + channel: FASHION_WEB + position: 1 + threshold: 1000 + earning_multiplier: 1.25 + color: '#c0c0c0' + benefits_description: 'A 25% points bonus on everything you buy.' + + - code: gold + name: Gold + channel: FASHION_WEB + position: 2 + threshold: 5000 + earning_multiplier: 1.5 + color: '#d4af37' + benefits_description: 'A 50% points bonus and access to member events.' + + setono_sylius_loyalty_earning_rule: + options: + custom: + - name: 'Base rate: 1 point per 1.00' + channel: FASHION_WEB + amount_type: per_amount + amount_configuration: + points: 1 + per_amount: 100 + + - name: 'Weekend double points (dry run)' + channel: FASHION_WEB + amount_type: multiplier + amount_configuration: + factor: 2 + conditions: + - type: day_of_week + configuration: + days: [6, 7] + dry_run: true + + - name: 'Registration bonus' + channel: FASHION_WEB + trigger: customer_registered + amount_type: fixed + amount_configuration: + points: 100 + + - name: 'Big-cart expression bonus (dry run)' + channel: FASHION_WEB + amount_type: expression + amount_configuration: + expression: 'basis > 50000 ? floor(basis / 50) : floor(basis / 100)' + dry_run: true + + setono_sylius_loyalty_account: + options: + custom: + - email: loyalty@example.com + channel: FASHION_WEB + history: + - type: manual_credit + points: 2000 + reason: goodwill + note: 'Fixture seed' + - type: earn_action + points: 300 + source_identifier: 'fixture:expiring' + expires_at: '+10 days' + - type: manual_debit + points: 150 + reason: correction + note: 'Fixture debit' + + - email: fresh@example.com + channel: FASHION_WEB + history: + - type: manual_credit + points: 100 + reason: goodwill + note: 'Below the redemption minimum' + + # Dedicated to the shop dashboard e2e spec — no other spec may + # touch this account, so its history stays exact + - email: history@example.com + channel: FASHION_WEB + history: + - type: manual_credit + points: 2000 + reason: goodwill + note: 'Fixture seed' + - type: earn_action + points: 300 + source_identifier: 'fixture:history-expiring' + expires_at: '+10 days' + - type: manual_debit + points: 150 + reason: correction + note: 'Fixture debit' diff --git a/tests/Application/config/packages/sylius_order_override.yaml b/tests/Application/config/packages/sylius_order_override.yaml new file mode 100644 index 0000000..9f43f61 --- /dev/null +++ b/tests/Application/config/packages/sylius_order_override.yaml @@ -0,0 +1,5 @@ +sylius_order: + resources: + order: + classes: + model: Setono\SyliusLoyaltyPlugin\Tests\Application\Entity\Order diff --git a/tests/Application/public/vendor/codemirror/bundle.js b/tests/Application/public/vendor/codemirror/bundle.js new file mode 100644 index 0000000..d4c6acb --- /dev/null +++ b/tests/Application/public/vendor/codemirror/bundle.js @@ -0,0 +1,14 @@ +var Fd=Object.defineProperty;var Tn=(n,e)=>{for(var t in e)Fd(n,t,{get:e[t],enumerable:!0})};var Nd={};Tn(Nd,{EditorView:()=>A,basicSetup:()=>jb,minimalSetup:()=>Ub});var yr=[],Ia=[];(()=>{let n="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e>1;if(n=Ia[i])e=i+1;else return!0;if(e==t)return!1}}function Ea(n){return n>=127462&&n<=127487}var Ra=8205;function Na(n,e,t=!0,i=!0){return(t?Fa:Wd)(n,e,i)}function Fa(n,e,t){if(e==n.length)return e;e&&Ha(n.charCodeAt(e))&&Wa(n.charCodeAt(e-1))&&e--;let i=gr(n,e);for(e+=Pa(i);e=0&&Ea(gr(n,o));)r++,o-=2;if(r%2==0)break;e+=2}else break}return e}function Wd(n,e,t){for(;e>1;){let i=Fa(n,e-2,t);if(i=56320&&n<57344}function Wa(n){return n>=55296&&n<56320}function Pa(n){return n<65536?1:2}var F=class n{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){[e,t]=ri(this,e,t);let s=[];return this.decompose(0,e,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(t,this.length,s,1),ii.from(s,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=ri(this,e,t);let i=[];return this.decompose(e,t,i,0),ii.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),s=new Pt(this),r=new Pt(e);for(let o=t,l=t;;){if(s.next(o),r.next(o),o=0,s.lineBreak!=r.lineBreak||s.done!=r.done||s.value!=r.value)return!1;if(l+=s.value.length,s.done||l>=i)return!0}}iter(e=1){return new Pt(this,e)}iterRange(e,t=this.length){return new En(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let s=this.line(e).from;i=this.iterRange(s,Math.max(s,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new Rn(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?n.empty:e.length<=32?new Te(e):ii.from(Te.split(e,[]))}},Te=class n extends F{constructor(e,t=Vd(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.text[r],l=s+o.length;if((t?i:l)>=e)return new xr(s,l,i,o);s=l+1,i++}}decompose(e,t,i,s){let r=e<=0&&t>=this.length?this:new n(Va(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(s&1){let o=i.pop(),l=Ln(r.text,o.text.slice(),0,r.length);if(l.length<=32)i.push(new n(l,o.length+r.length));else{let a=l.length>>1;i.push(new n(l.slice(0,a)),new n(l.slice(a)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof n))return super.replace(e,t,i);[e,t]=ri(this,e,t);let s=Ln(this.text,Ln(i.text,Va(this.text,0,e)),t),r=this.length+i.length-(t-e);return s.length<=32?new n(s,r):ii.from(n.split(s,[]),r)}sliceString(e,t=this.length,i=` +`){[e,t]=ri(this,e,t);let s="";for(let r=0,o=0;r<=t&&oe&&o&&(s+=i),er&&(s+=l.slice(Math.max(0,e-r),t-r)),r=a+1}return s}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],s=-1;for(let r of e)i.push(r),s+=r.length+1,i.length==32&&(t.push(new n(i,s)),i=[],s=-1);return s>-1&&t.push(new n(i,s)),t}},ii=class n extends F{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.children[r],l=s+o.length,a=i+o.lines-1;if((t?a:l)>=e)return o.lineInner(e,t,i,s);s=l+1,i=a+1}}decompose(e,t,i,s){for(let r=0,o=0;o<=t&&r=o){let h=s&((o<=e?1:0)|(a>=t?2:0));o>=e&&a<=t&&!h?i.push(l):l.decompose(e-o,t-o,i,h)}o=a+1}}replace(e,t,i){if([e,t]=ri(this,e,t),i.lines=r&&t<=l){let a=o.replace(e-r,t-r,i),h=this.lines-o.lines+a.lines;if(a.lines>4&&a.lines>h>>6){let c=this.children.slice();return c[s]=a,new n(c,this.length-(t-e)+i.length)}return super.replace(r,l,a)}r=l+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=` +`){[e,t]=ri(this,e,t);let s="";for(let r=0,o=0;re&&r&&(s+=i),eo&&(s+=l.sliceString(e-o,t-o,i)),o=a+1}return s}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof n))return 0;let i=0,[s,r,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;s+=t,r+=t){if(s==o||r==l)return i;let a=this.children[s],h=e.children[r];if(a!=h)return i+a.scanIdentical(h,t);i+=a.length+1}}static from(e,t=e.reduce((i,s)=>i+s.length+1,-1)){let i=0;for(let d of e)i+=d.lines;if(i<32){let d=[];for(let p of e)p.flatten(d);return new Te(d,t)}let s=Math.max(32,i>>5),r=s<<1,o=s>>1,l=[],a=0,h=-1,c=[];function f(d){let p;if(d.lines>r&&d instanceof n)for(let m of d.children)f(m);else d.lines>o&&(a>o||!a)?(u(),l.push(d)):d instanceof Te&&a&&(p=c[c.length-1])instanceof Te&&d.lines+p.lines<=32?(a+=d.lines,h+=d.length+1,c[c.length-1]=new Te(p.text.concat(d.text),p.length+1+d.length)):(a+d.lines>s&&u(),a+=d.lines,h+=d.length+1,c.push(d))}function u(){a!=0&&(l.push(c.length==1?c[0]:n.from(c,h)),h=-1,a=c.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new n(l,t)}};F.empty=new Te([""],0);function Vd(n){let e=-1;for(let t of n)e+=t.length+1;return e}function Ln(n,e,t=0,i=1e9){for(let s=0,r=0,o=!0;r=t&&(a>i&&(l=l.slice(0,i-s)),s0?1:(e instanceof Te?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,s=this.nodes[i],r=this.offsets[i],o=r>>1,l=s instanceof Te?s.text.length:s.children.length;if(o==(t>0?l:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=` +`,this;e--}else if(s instanceof Te){let a=s.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,a.length>Math.max(0,e))return this.value=e==0?a:t>0?a.slice(e):a.slice(0,a.length-e),this;e-=a.length}else{let a=s.children[o+(t<0?-1:0)];e>a.length?(e-=a.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(a),this.offsets.push(t>0?1:(a instanceof Te?a.text.length:a.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}},En=class{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new Pt(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:s}=this.cursor.next(e);return this.pos+=(s.length+e)*t,this.value=s.length<=i?s:t<0?s.slice(s.length-i):s.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}},Rn=class{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:s}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=s,this.afterBreak=!1),this}get lineBreak(){return!1}};typeof Symbol<"u"&&(F.prototype[Symbol.iterator]=function(){return this.iter()},Pt.prototype[Symbol.iterator]=En.prototype[Symbol.iterator]=Rn.prototype[Symbol.iterator]=function(){return this});var xr=class{constructor(e,t,i,s){this.from=e,this.to=t,this.number=i,this.text=s}get length(){return this.to-this.from}};function ri(n,e,t){return e=Math.max(0,Math.min(n.length,e)),[e,Math.max(e,Math.min(n.length,t))]}function te(n,e,t=!0,i=!0){return Na(n,e,t,i)}function zd(n){return n>=56320&&n<57344}function qd(n){return n>=55296&&n<56320}function he(n,e){let t=n.charCodeAt(e);if(!qd(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return zd(i)?(t-55296<<10)+(i-56320)+65536:t}function Ii(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function De(n){return n<65536?1:2}var wr=/\r\n?|\n/,se=(function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n})(se||(se={})),rt=class n{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return r+(e-s);r+=l}else{if(i!=se.Simple&&h>=e&&(i==se.TrackDel&&se||i==se.TrackBefore&&se))return null;if(h>e||h==e&&t<0&&!l)return e==s||t<0?r:r+a;r+=a}s=h}if(e>s)throw new RangeError(`Position ${e} is out of range for changeset of length ${s}`);return r}touchesRange(e,t=e){for(let i=0,s=0;i=0&&s<=t&&l>=e)return st?"cover":!0;s=l}return!1}toString(){let e="";for(let t=0;t=0?":"+s:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new n(e)}static create(e){return new n(e)}},ye=class n extends rt{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return vr(this,(t,i,s,r,o)=>e=e.replace(s,s+(i-t),o),!1),e}mapDesc(e,t=!1){return kr(this,e,t,!0)}invert(e){let t=this.sections.slice(),i=[];for(let s=0,r=0;s=0){t[s]=l,t[s+1]=o;let a=s>>1;for(;i.length0&>(i,t,r.text),r.forward(c),l+=c}let h=e[o++];for(;l>1].toJSON()))}return e}static of(e,t,i){let s=[],r=[],o=0,l=null;function a(c=!1){if(!c&&!s.length)return;ou||f<0||u>t)throw new RangeError(`Invalid change range ${f} to ${u} (in doc of length ${t})`);let p=d?typeof d=="string"?F.of(d.split(i||wr)):d:F.empty,m=p.length;if(f==u&&m==0)return;fo&&de(s,f-o,-1),de(s,u-f,m),gt(r,s,p),o=u}}return h(e),a(!l),l}static empty(e){return new n(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],i=[];for(let s=0;sl&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(r.length==1)t.push(r[0],0);else{for(;i.length=0&&t<=0&&t==n[s+1]?n[s]+=e:s>=0&&e==0&&n[s]==0?n[s+1]+=t:i?(n[s]+=e,n[s+1]+=t):n.push(e,t)}function gt(n,e,t){if(t.length==0)return;let i=e.length-2>>1;if(i>1])),!(t||o==n.sections.length||n.sections[o+1]<0);)l=n.sections[o++],a=n.sections[o++];e(s,h,r,c,f),s=h,r=c}}}function kr(n,e,t,i=!1){let s=[],r=i?[]:null,o=new It(n),l=new It(e);for(let a=-1;;){if(o.done&&l.len||l.done&&o.len)throw new Error("Mismatched change set lengths");if(o.ins==-1&&l.ins==-1){let h=Math.min(o.len,l.len);de(s,h,-1),o.forward(h),l.forward(h)}else if(l.ins>=0&&(o.ins<0||a==o.i||o.off==0&&(l.len=0&&a=0){let h=0,c=o.len;for(;c;)if(l.ins==-1){let f=Math.min(c,l.len);h+=f,c-=f,l.forward(f)}else if(l.ins==0&&l.lena||o.ins>=0&&o.len>a)&&(l||i.length>h),r.forward2(a),o.forward(a)}}}}var It=class{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?F.empty:e[t]}textBit(e){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length&&!e?F.empty:t[i].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}},Lt=class n{constructor(e,t,i,s){this.from=e,this.to=t,this.flags=i,this.goalColumn=s}get anchor(){return this.flags&32?this.to:this.from}get head(){return this.flags&32?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&8?-1:this.flags&16?1:0}get undirectional(){return(this.flags&64)>0}get bidiLevel(){let e=this.flags&7;return e==7?null:e}map(e,t=-1){let i,s;return this.empty?i=s=e.mapPos(this.from,t):(i=e.mapPos(this.from,1),s=e.mapPos(this.to,-1)),i==this.from&&s==this.to?this:new n(i,s,this.flags,this.goalColumn)}extend(e,t=e,i=0){if(e<=this.anchor&&t>=this.anchor)return b.range(e,t,void 0,void 0,i);let s=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return b.range(this.anchor,s,void 0,void 0,i)}eq(e,t=!1){return this.anchor==e.anchor&&this.head==e.head&&this.goalColumn==e.goalColumn&&(!t||!this.empty||this.assoc==e.assoc)}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return b.range(e.anchor,e.head)}static create(e,t,i,s){return new n(e,t,i,s)}},b=class n{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:n.create(this.ranges.map(i=>i.map(e,t)),this.mainIndex)}eq(e,t=!1){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let i=0;ie.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new n(e.ranges.map(t=>Lt.fromJSON(t)),e.main)}static single(e,t=e){return new n([n.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let i=0,s=0;ss.from-r.from),t=e.indexOf(i);for(let s=1;sr.head?n.range(a,l):n.range(l,a))}}return new n(e,t)}};function Ua(n,e){for(let t of n.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}var Er=0,C=class n{constructor(e,t,i,s,r){this.combine=e,this.compareInput=t,this.compare=i,this.isStatic=s,this.id=Er++,this.default=e([]),this.extensions=typeof r=="function"?r(this):r}get reader(){return this}static define(e={}){return new n(e.combine||(t=>t),e.compareInput||((t,i)=>t===i),e.compare||(e.combine?(t,i)=>t===i:Rr),!!e.static,e.enables)}of(e){return new ni([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new ni(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new ni(e,this,2,t)}from(e,t){return t||(t=i=>i),this.compute([e],i=>t(i.field(e)))}};function Rr(n,e){return n==e||n.length==e.length&&n.every((t,i)=>t===e[i])}var ni=class{constructor(e,t,i,s){this.dependencies=e,this.facet=t,this.type=i,this.value=s,this.id=Er++}dynamicSlot(e){var t;let i=this.value,s=this.facet.compareInput,r=this.id,o=e[r]>>1,l=this.type==2,a=!1,h=!1,c=[];for(let f of this.dependencies)f=="doc"?a=!0:f=="selection"?h=!0:(((t=e[f.id])!==null&&t!==void 0?t:1)&1)==0&&c.push(e[f.id]);return{create(f){return f.values[o]=i(f),1},update(f,u){if(a&&u.docChanged||h&&(u.docChanged||u.selection)||Sr(f,c)){let d=i(f);if(l?!za(d,f.values[o],s):!s(d,f.values[o]))return f.values[o]=d,1}return 0},reconfigure:(f,u)=>{let d,p=u.config.address[r];if(p!=null){let m=Fn(u,p);if(this.dependencies.every(g=>g instanceof C?u.facet(g)===f.facet(g):g instanceof J?u.field(g,!1)==f.field(g,!1):!0)||(l?za(d=i(f),m,s):s(d=i(f),m)))return f.values[o]=m,0}else d=i(f);return f.values[o]=d,1}}}get extension(){return this}};function za(n,e,t){if(n.length!=e.length)return!1;for(let i=0;in[a.id]),s=t.map(a=>a.type),r=i.filter(a=>!(a&1)),o=n[e.id]>>1;function l(a){let h=[];for(let c=0;ci===s),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(Dn).find(i=>i.field==this);return(t?.create||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:i=>(i.values[t]=this.create(i),1),update:(i,s)=>{let r=i.values[t],o=this.updateF(r,s);return this.compareF(r,o)?0:(i.values[t]=o,1)},reconfigure:(i,s)=>{let r=i.facet(Dn),o=s.facet(Dn),l;return(l=r.find(a=>a.field==this))&&l!=o.find(a=>a.field==this)?(i.values[t]=l.create(i),1):s.config.address[this.id]!=null?(i.values[t]=s.field(this),0):(i.values[t]=this.create(i),1)}}}init(e){return[this,Dn.of({field:this,create:e})]}get extension(){return this}},Et={lowest:4,low:3,default:2,high:1,highest:0};function Bi(n){return e=>new Pn(e,n)}var Oe={highest:Bi(Et.highest),high:Bi(Et.high),default:Bi(Et.default),low:Bi(Et.low),lowest:Bi(Et.lowest)},Pn=class{constructor(e,t){this.inner=e,this.prec=t}get extension(){return this}},In=class n{of(e){return new Ei(this,e)}reconfigure(e){return n.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}},Ei=class{constructor(e,t){this.compartment=e,this.inner=t}get extension(){return this}},Nn=class n{constructor(e,t,i,s,r,o){for(this.base=e,this.compartments=t,this.dynamicSlots=i,this.address=s,this.staticValues=r,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,i){let s=[],r=Object.create(null),o=new Map;for(let u of $d(e,t,o))u instanceof J?s.push(u):(r[u.facet.id]||(r[u.facet.id]=[])).push(u);let l=Object.create(null),a=[],h=[];for(let u of s)l[u.id]=h.length<<1,h.push(d=>u.slot(d));let c=i?.config.facets;for(let u in r){let d=r[u],p=d[0].facet,m=c&&c[u]||[];if(d.every(g=>g.type==0))if(l[p.id]=a.length<<1|1,Rr(m,d))a.push(i.facet(p));else{let g=p.combine(d.map(y=>y.value));a.push(i&&p.compare(g,i.facet(p))?i.facet(p):g)}else{for(let g of d)g.type==0?(l[g.id]=a.length<<1|1,a.push(g.value)):(l[g.id]=h.length<<1,h.push(y=>g.dynamicSlot(y)));l[p.id]=h.length<<1,h.push(g=>Kd(g,p,d))}}let f=h.map(u=>u(l));return new n(e,o,f,l,a,r)}};function $d(n,e,t){let i=[[],[],[],[],[]],s=new Map;function r(o,l){let a=s.get(o);if(a!=null){if(a<=l)return;let h=i[a].indexOf(o);h>-1&&i[a].splice(h,1),o instanceof Ei&&t.delete(o.compartment)}if(s.set(o,l),Array.isArray(o))for(let h of o)r(h,l);else if(o instanceof Ei){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let h=e.get(o.compartment)||o.inner;t.set(o.compartment,h),r(h,l)}else if(o instanceof Pn)r(o.inner,o.prec);else if(o instanceof J)i[l].push(o),o.provides&&r(o.provides,l);else if(o instanceof ni)i[l].push(o),o.facet.extensions&&r(o.facet.extensions,Et.default);else{let h=o.extension;if(!h)throw new Error(`Unrecognized extension value in extension set (${o}).`);if(h==o)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);r(h,l)}}return r(n,Et.default),i.reduce((o,l)=>o.concat(l))}function Li(n,e){if(e&1)return 2;let t=e>>1,i=n.status[t];if(i==4)throw new Error("Cyclic dependency between fields and/or facets");if(i&2)return i;n.status[t]=4;let s=n.computeSlot(n,n.config.dynamicSlots[t]);return n.status[t]=2|s}function Fn(n,e){return e&1?n.config.staticValues[e>>1]:n.values[e>>1]}var Ga=C.define(),Cr=C.define({combine:n=>n.some(e=>e),static:!0}),_a=C.define({combine:n=>n.length?n[0]:void 0,static:!0}),Ya=C.define(),Ja=C.define(),Xa=C.define(),Qa=C.define({combine:n=>n.length?n[0]:!1}),ke=class{constructor(e,t){this.type=e,this.value=t}static define(){return new Ar}},Ar=class{of(e){return new ke(this,e)}},Mr=class{constructor(e){this.map=e}of(e){return new L(this,e)}},L=class n{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new n(this.type,t)}is(e){return this.type==e}static define(e={}){return new Mr(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let i=[];for(let s of e){let r=s.map(t);r&&i.push(r)}return i}};L.reconfigure=L.define();L.appendConfig=L.define();var ie=class n{constructor(e,t,i,s,r,o){this.startState=e,this.changes=t,this.selection=i,this.effects=s,this.annotations=r,this.scrollIntoView=o,this._doc=null,this._state=null,i&&Ua(i,t.newLength),r.some(l=>l.type==n.time)||(this.annotations=r.concat(n.time.of(Date.now())))}static create(e,t,i,s,r,o){return new n(e,t,i,s,r,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(n.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}};ie.time=ke.define();ie.userEvent=ke.define();ie.addToHistory=ke.define();ie.remote=ke.define();function jd(n,e){let t=[];for(let i=0,s=0;;){let r,o;if(i=n[i]))r=n[i++],o=n[i++];else if(s=0;s--){let r=i[s](n);r instanceof ie?n=r:Array.isArray(r)&&r.length==1&&r[0]instanceof ie?n=r[0]:n=eh(e,si(r),!1)}return n}function Gd(n){let e=n.startState,t=e.facet(Xa),i=n;for(let s=t.length-1;s>=0;s--){let r=t[s](n);r&&Object.keys(r).length&&(i=Za(i,Tr(e,r,n.changes.newLength),!0))}return i==n?n:ie.create(e,n.changes,n.selection,i.effects,i.annotations,i.scrollIntoView)}var _d=[];function si(n){return n==null?_d:Array.isArray(n)?n:[n]}var K=(function(n){return n[n.Word=0]="Word",n[n.Space=1]="Space",n[n.Other=2]="Other",n})(K||(K={})),Yd=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/,Dr;try{Dr=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function Jd(n){if(Dr)return Dr.test(n);for(let e=0;e"\x80"&&(t.toUpperCase()!=t.toLowerCase()||Yd.test(t)))return!0}return!1}function Xd(n){return e=>{if(!/\S/.test(e))return K.Space;if(Jd(e))return K.Word;for(let t=0;t-1)return K.Word;return K.Other}}var j=class n{constructor(e,t,i,s,r,o){this.config=e,this.doc=t,this.selection=i,this.values=s,this.status=e.statusTemplate.slice(),this.computeSlot=r,o&&(o._state=this);for(let l=0;ls.set(h,a)),t=null),s.set(l.value.compartment,l.value.extension)):l.is(L.reconfigure)?(t=null,i=l.value):l.is(L.appendConfig)&&(t=null,i=si(i).concat(l.value));let r;t?r=e.startState.values.slice():(t=Nn.resolve(i,s,this),r=new n(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(a,h)=>h.reconfigure(a,this),null).values);let o=e.startState.facet(Cr)?e.newSelection:e.newSelection.asSingle();new n(t,e.newDoc,o,r,(l,a)=>a.update(l,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:b.cursor(t.from+e.length)}))}changeByRange(e){let t=this.selection,i=e(t.ranges[0]),s=this.changes(i.changes),r=[i.range],o=si(i.effects);for(let l=1;lo.spec.fromJSON(l,a)))}}return n.create({doc:e.doc,selection:b.fromJSON(e.selection),extensions:t.extensions?s.concat([t.extensions]):s})}static create(e={}){let t=Nn.resolve(e.extensions||[],new Map),i=e.doc instanceof F?e.doc:F.of((e.doc||"").split(t.staticFacet(n.lineSeparator)||wr)),s=e.selection?e.selection instanceof b?e.selection:b.single(e.selection.anchor,e.selection.head):b.single(0);return Ua(s,i.length),t.staticFacet(Cr)||(s=s.asSingle()),new n(t,i,s,t.dynamicSlots.map(()=>null),(r,o)=>o.create(r),null)}get tabSize(){return this.facet(n.tabSize)}get lineBreak(){return this.facet(n.lineSeparator)||` +`}get readOnly(){return this.facet(Qa)}phrase(e,...t){for(let i of this.facet(n.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,s)=>{if(s=="$")return"$";let r=+(s||1);return!r||r>t.length?i:t[r-1]})),e}languageDataAt(e,t,i=-1){let s=[];for(let r of this.facet(Ga))for(let o of r(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&s.push(o[e]);return s}charCategorizer(e){let t=this.languageDataAt("wordChars",e);return Xd(t.length?t[0]:"")}wordAt(e){let{text:t,from:i,length:s}=this.doc.lineAt(e),r=this.charCategorizer(e),o=e-i,l=e-i;for(;o>0;){let a=te(t,o,!1);if(r(t.slice(a,o))!=K.Word)break;o=a}for(;ln.length?n[0]:4});j.lineSeparator=_a;j.readOnly=Qa;j.phrases=C.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(s=>n[s]==e[s])}});j.languageData=Ga;j.changeFilter=Ya;j.transactionFilter=Ja;j.transactionExtender=Xa;In.reconfigure=L.define();function ce(n,e,t={}){let i={};for(let s of n)for(let r of Object.keys(s)){let o=s[r],l=i[r];if(l===void 0)i[r]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,r))i[r]=t[r](l,o);else throw new Error("Config merge conflict for field "+r)}for(let s in e)i[s]===void 0&&(i[s]=e[s]);return i}var Pe=class{eq(e){return this==e}range(e,t=e){return Ri.create(e,t,this)}};Pe.prototype.startSide=Pe.prototype.endSide=0;Pe.prototype.point=!1;Pe.prototype.mapMode=se.TrackDel;function Pr(n,e){return n==e||n.constructor==e.constructor&&n.eq(e)}var Ri=class n{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new n(e,t,i)}};function Or(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}var Br=class n{constructor(e,t,i,s){this.from=e,this.to=t,this.value=i,this.maxPoint=s}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,s=0){let r=i?this.to:this.from;for(let o=s,l=r.length;;){if(o==l)return o;let a=o+l>>1,h=r[a]-e||(i?this.value[a].endSide:this.value[a].startSide)-t;if(a==o)return h>=0?o:l;h>=0?l=a:o=a+1}}between(e,t,i,s){for(let r=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,r);rd||u==d&&h.startSide>0&&h.endSide<=0)continue;(d-u||h.endSide-h.startSide)<0||(o<0&&(o=u),h.point&&(l=Math.max(l,d-u)),i.push(h),s.push(u-o),r.push(d-o))}return{mapped:i.length?new n(s,r,i,l):null,pos:o}}},I=class n{constructor(e,t,i,s){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=s}static create(e,t,i,s){return new n(e,t,i,s)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:s=0,filterTo:r=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Or)),this.isEmpty)return t.length?n.of(t):this;let l=new Hn(this,null,-1).goto(0),a=0,h=[],c=new Se;for(;l.value||a=0){let f=t[a++];c.addInner(f.from,f.to,f.value)||h.push(f)}else l.rangeIndex==1&&l.chunkIndexthis.chunkEnd(l.chunkIndex)||rl.to||r=r&&e<=r+o.length&&o.between(r,e-r,t-r,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return Pi.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return Pi.from(e).goto(t)}static compare(e,t,i,s,r=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),a=qa(o,l,i),h=new Rt(o,a,r),c=new Rt(l,a,r);i.iterGaps((f,u,d)=>Ka(h,f,c,u,d,s)),i.empty&&i.length==0&&Ka(h,0,c,0,0,s)}static eq(e,t,i=0,s){s==null&&(s=999999999);let r=e.filter(c=>!c.isEmpty&&t.indexOf(c)<0),o=t.filter(c=>!c.isEmpty&&e.indexOf(c)<0);if(r.length!=o.length)return!1;if(!r.length)return!0;let l=qa(r,o),a=new Rt(r,l,0).goto(i),h=new Rt(o,l,0).goto(i);for(;;){if(a.to!=h.to||!Lr(a.active,h.active)||a.point&&(!h.point||!Pr(a.point,h.point)))return!1;if(a.to>s)return!0;a.next(),h.next()}}static spans(e,t,i,s,r=-1){let o=new Rt(e,null,r).goto(t),l=t,a=o.openStart;for(;;){let h=Math.min(o.to,i);if(o.point){let c=o.activeForPoint(o.to),f=o.pointFroml&&(s.span(l,h,o.active,a),a=o.openEnd(h));if(o.to>i)return a+(o.point&&o.to>i?1:0);l=o.to,o.next()}}static of(e,t=!1){let i=new Se;for(let s of e instanceof Ri?[e]:t?Qd(e):e)i.add(s.from,s.to,s.value);return i.finish()}static join(e){if(!e.length)return n.empty;let t=e[e.length-1];for(let i=e.length-2;i>=0;i--)for(let s=e[i];s!=n.empty;s=s.nextLayer)t=new n(s.chunkPos,s.chunk,t,Math.max(s.maxPoint,t.maxPoint));return t}};I.empty=new I([],[],null,-1);function Qd(n){if(n.length>1)for(let e=n[0],t=1;t0)return n.slice().sort(Or);e=i}return n}I.empty.nextLayer=I.empty;var Se=class n{finishChunk(e){this.chunks.push(new Br(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,i){this.addInner(e,t,i)||(this.nextLayer||(this.nextLayer=new n)).add(e,t,i)}addInner(e,t,i){let s=e-this.lastTo||i.startSide-this.last.endSide;if(s<=0&&(e-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return s<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=i,this.lastFrom=e,this.lastTo=t,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let i=t.value.length-1;return this.last=t.value[i],this.lastFrom=t.from[i]+e,this.lastTo=t.to[i]+e,!0}finish(){return this.finishInner(I.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=I.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}};function qa(n,e,t){let i=new Map;for(let r of n)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=i&&s.push(new Hn(o,t,i,r));return s.length==1?s[0]:new n(s)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let i of this.heap)i.goto(e,t);for(let i=this.heap.length>>1;i>=0;i--)br(this.heap,i);return this.next(),this}forward(e,t){for(let i of this.heap)i.forward(e,t);for(let i=this.heap.length>>1;i>=0;i--)br(this.heap,i);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),br(this.heap,0)}}};function br(n,e){for(let t=n[e];;){let i=(e<<1)+1;if(i>=n.length)break;let s=n[i];if(i+1=0&&(s=n[i+1],i++),t.compare(s)<0)break;n[i]=t,n[e]=s,e=i}}var Rt=class{constructor(e,t,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=Pi.from(e,t,i)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){On(this.active,e),On(this.activeTo,e),On(this.activeRank,e),this.minActive=$a(this.active,this.activeTo)}addActive(e){let t=0,{value:i,to:s,rank:r}=this.cursor;for(;t0;)t++;Bn(this.active,t,i),Bn(this.activeTo,t,s),Bn(this.activeRank,t,r),e&&Bn(e,t,this.cursor.from),this.minActive=$a(this.active,this.activeTo)}next(){let e=this.to,t=this.point;this.point=null;let i=this.openStart<0?[]:null;for(;;){let s=this.minActive;if(s>-1&&(this.activeTo[s]-this.cursor.from||this.active[s].endSide-this.cursor.startSide)<0){if(this.activeTo[s]>e){this.to=this.activeTo[s],this.endSide=this.active[s].endSide;break}this.removeActive(s),i&&On(i,s)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let r=this.cursor.value;if(!r.point)this.addActive(i),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&i[s]=0&&!(this.activeRank[i]e||this.activeTo[i]==e&&this.active[i].endSide>=this.point.endSide)&&t.push(this.active[i]);return t.reverse()}openEnd(e){let t=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>e;i--)t++;return t}};function Ka(n,e,t,i,s,r){n.goto(e),t.goto(i);let o=i+s,l=i,a=i-e,h=!!r.boundChange;for(let c=!1;;){let f=n.to+a-t.to,u=f||n.endSide-t.endSide,d=u<0?n.to+a:t.to,p=Math.min(d,o);if(n.point||t.point?(n.point&&t.point&&Pr(n.point,t.point)&&Lr(n.activeForPoint(n.to),t.activeForPoint(t.to))||r.comparePoint(l,p,n.point,t.point),c=!1):(c&&r.boundChange(l),p>l&&!Lr(n.active,t.active)&&r.compareRange(l,p,n.active,t.active),h&&po)break;l=d,u<=0&&n.next(),u>=0&&t.next()}}function Lr(n,e){if(n.length!=e.length)return!1;for(let t=0;t=e;i--)n[i+1]=n[i];n[e]=t}function $a(n,e){let t=-1,i=1e9;for(let s=0;s=e)return s;if(s==n.length)break;r+=n.charCodeAt(s)==9?t-r%t:1,s=te(n,s)}return i===!0?-1:n.length}var th=typeof Symbol>"u"?"__\u037C":Symbol.for("\u037C"),Ir=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),ih=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{},Ie=class{constructor(e,t){this.rules=[];let{finish:i}=t||{};function s(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function r(o,l,a,h){let c=[],f=/^@(\w+)\b/.exec(o[0]),u=f&&f[1]=="keyframes";if(f&&l==null)return a.push(o[0]+";");for(let d in l){let p=l[d];if(/&/.test(d))r(d.split(/,\s*/).map(m=>o.map(g=>m.replace(/&/,g))).reduce((m,g)=>m.concat(g)),p,a);else if(p&&typeof p=="object"){if(!f)throw new RangeError("The value of a property ("+d+") should be a primitive value.");r(s(d),p,c,u)}else p!=null&&c.push(d.replace(/_.*/,"").replace(/[A-Z]/g,m=>"-"+m.toLowerCase())+": "+p+";")}(c.length||u)&&a.push((i&&!f&&!h?o.map(i):o).join(", ")+" {"+c.join(" ")+"}")}for(let o in e)r(s(o),e[o],this.rules)}getRules(){return this.rules.join(` +`)}static newName(){let e=ih[th]||1;return ih[th]=e+1,"\u037C"+e.toString(36)}static mount(e,t,i){let s=e[Ir],r=i&&i.nonce;s?r&&s.setNonce(r):s=new Nr(e,r),s.mount(Array.isArray(t)?t:[t],e)}},nh=new Map,Nr=class{constructor(e,t){let i=e.ownerDocument||e,s=i.defaultView;if(!e.head&&e.adoptedStyleSheets&&s.CSSStyleSheet){let r=nh.get(i);if(r)return e[Ir]=r;this.sheet=new s.CSSStyleSheet,nh.set(i,this)}else this.styleTag=i.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[Ir]=this}mount(e,t){let i=this.sheet,s=0,r=0;for(let o=0;o-1&&(this.modules.splice(a,1),r--,a=-1),a==-1){if(this.modules.splice(r++,0,l),i)for(let h=0;h",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},Zd=typeof navigator<"u"&&/Mac/.test(navigator.platform),ep=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(ne=0;ne<10;ne++)lt[48+ne]=lt[96+ne]=String(ne);var ne;for(ne=1;ne<=24;ne++)lt[ne+111]="F"+ne;var ne;for(ne=65;ne<=90;ne++)lt[ne]=String.fromCharCode(ne+32),oi[ne]=String.fromCharCode(ne);var ne;for(Vn in lt)oi.hasOwnProperty(Vn)||(oi[Vn]=lt[Vn]);var Vn;function sh(n){var e=Zd&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||ep&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?oi:lt)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function W(){var n=arguments[0];typeof n=="string"&&(n=document.createElement(n));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var s=t[i];typeof s=="string"?n.setAttribute(i,s):s!=null&&(n[i]=s)}e++}for(;e2),D={mac:ah||/Mac/.test(be.platform),windows:/Win/.test(be.platform),linux:/Linux|X11/.test(be.platform),ie:xs,ie_version:$h?Ur.documentMode||6:_r?+_r[1]:Gr?+Gr[1]:0,gecko:oh,gecko_version:oh?+(/Firefox\/(\d+)/.exec(be.userAgent)||[0,0])[1]:0,chrome:!!Fr,chrome_version:Fr?+Fr[1]:0,ios:ah,android:/Android\b/.test(be.userAgent),webkit:lh,webkit_version:lh?+(/\bAppleWebKit\/(\d+)/.exec(be.userAgent)||[0,0])[1]:0,safari:Yr,safari_version:Yr?+(/\bVersion\/(\d+(\.\d+)?)/.exec(be.userAgent)||[0,0])[1]:0,tabSize:Ur.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function qo(n,e){for(let t in n)t=="class"&&e.class?e.class+=" "+n.class:t=="style"&&e.style?e.style+=";"+n.style:e[t]=n[t];return e}var ts=Object.create(null);function Ko(n,e,t){if(n==e)return!0;n||(n=ts),e||(e=ts);let i=Object.keys(n),s=Object.keys(e);if(i.length-(t&&i.indexOf(t)>-1?1:0)!=s.length-(t&&s.indexOf(t)>-1?1:0))return!1;for(let r of i)if(r!=t&&(s.indexOf(r)==-1||n[r]!==e[r]))return!1;return!0}function tp(n,e){for(let t=n.attributes.length-1;t>=0;t--){let i=n.attributes[t].name;e[i]==null&&n.removeAttribute(i)}for(let t in e){let i=e[t];t=="style"?n.style.cssText=i:n.getAttribute(t)!=i&&n.setAttribute(t,i)}}function hh(n,e,t){let i=!1;if(e)for(let s in e)t&&s in t||(i=!0,s=="style"?n.style.cssText="":n.removeAttribute(s));if(t)for(let s in t)e&&e[s]==t[s]||(i=!0,s=="style"?n.style.cssText=t[s]:n.setAttribute(s,t[s]));return i}function ip(n){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new Wt(e,t,t,i,e.widget||null,!1)}static replace(e){let t=!!e.block,i,s;if(e.isBlockGap)i=-5e8,s=4e8;else{let{start:r,end:o}=jh(e,t);i=(r?t?-3e8:-1:5e8)-1,s=(o?t?2e8:1:-6e8)+1}return new Wt(e,i,s,t,e.widget||null,!0)}static line(e){return new Yi(e)}static set(e,t=!1){return I.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}};B.none=I.empty;var _i=class n extends B{constructor(e){let{start:t,end:i}=jh(e);super(t?-1:5e8,i?1:-6e8,null,e),this.tagName=e.tagName||"span",this.attrs=e.class&&e.attributes?qo(e.attributes,{class:e.class}):e.class?{class:e.class}:e.attributes||ts}eq(e){return this==e||e instanceof n&&this.tagName==e.tagName&&Ko(this.attrs,e.attrs)}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}};_i.prototype.point=!1;var Yi=class n extends B{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof n&&this.spec.class==e.spec.class&&Ko(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}};Yi.prototype.mapMode=se.TrackBefore;Yi.prototype.point=!0;var Wt=class n extends B{constructor(e,t,i,s,r,o){super(t,i,r,e),this.block=s,this.isReplace=o,this.mapMode=s?t<=0?se.TrackBefore:se.TrackAfter:se.TrackDel}get type(){return this.startSide!=this.endSide?fe.WidgetRange:this.startSide<=0?fe.WidgetBefore:fe.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&(this.widget.estimatedHeight>=5||this.widget.lineBreaks>0)}eq(e){return e instanceof n&&np(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}};Wt.prototype.point=!0;function jh(n,e=!1){let{inclusiveStart:t,inclusiveEnd:i}=n;return t==null&&(t=n.inclusive),i==null&&(i=n.inclusive),{start:t??e,end:i??e}}function np(n,e){return n==e||!!(n&&e&&n.compare(e))}function ui(n,e,t,i=0){let s=t.length-1;s>=0&&t[s]+i>=n?t[s]=Math.max(t[s],e):t.push(n,e)}var is=class n extends Pe{constructor(e,t,i){super(),this.tagName=e,this.attributes=t,this.rank=i}eq(e){return e==this||e instanceof n&&this.tagName==e.tagName&&Ko(this.attributes,e.attributes)}static create(e){return new n(e.tagName,e.attributes||ts,e.rank==null?50:Math.max(0,Math.min(e.rank,100)))}static set(e,t=!1){return I.of(e,t)}};is.prototype.startSide=is.prototype.endSide=-1;function Ji(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Jr(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function Wi(n,e){if(!e.anchorNode)return!1;try{return Jr(n,e.anchorNode)}catch{return!1}}function Jn(n){return n.nodeType==3?Xi(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function Vi(n,e,t,i){return t?ch(n,e,t,i,-1)||ch(n,e,t,i,1):!1}function xt(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function ns(n){return n.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(n.nodeName)}function ch(n,e,t,i,s){for(;;){if(n==t&&e==i)return!0;if(e==(s<0?0:ct(n))){if(n.nodeName=="DIV")return!1;let r=n.parentNode;if(!r||r.nodeType!=1)return!1;e=xt(n)+(s<0?0:1),n=r}else if(n.nodeType==1){if(n=n.childNodes[e+(s<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=s<0?ct(n):0}else return!1}}function ct(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function ss(n,e){let{left:t,right:i}=n;if(t==i)return n;let s=e?t:i;return{left:s,right:s,top:n.top,bottom:n.bottom}}function sp(n){let e=n.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function Uh(n,e){let t=e.width/n.offsetWidth,i=e.height/n.offsetHeight;return(t>.995&&t<1.005||!isFinite(t)||Math.abs(e.width-n.offsetWidth)<1)&&(t=1),(i>.995&&i<1.005||!isFinite(i)||Math.abs(e.height-n.offsetHeight)<1)&&(i=1),{scaleX:t,scaleY:i}}function rp(n,e,t,i,s,r,o,l){let a=n.ownerDocument,h=a.defaultView||window;for(let c=n,f=!1;c&&!f;)if(c.nodeType==1){let u,d=c==a.body,p=1,m=1;if(d)u=sp(h);else{if(/^(fixed|sticky)$/.test(getComputedStyle(c).position)&&(f=!0),c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.assignedSlot||c.parentNode;continue}let x=c.getBoundingClientRect();({scaleX:p,scaleY:m}=Uh(c,x)),u={left:x.left,right:x.left+c.clientWidth*p,top:x.top,bottom:x.top+c.clientHeight*m}}let g=0,y=0;if(s=="nearest")e.top0&&e.bottom>u.bottom+y&&(y=e.bottom-u.bottom+o)):e.bottom>u.bottom-o&&(y=e.bottom-u.bottom+o,t<0&&e.top-y0&&e.right>u.right+g&&(g=e.right-u.right+r)):e.right>u.right-r&&(g=e.right-u.right+r,t<0&&e.leftu.bottom||e.leftu.right)&&(e={left:Math.max(e.left,u.left),right:Math.min(e.right,u.right),top:Math.max(e.top,u.top),bottom:Math.min(e.bottom,u.bottom)}),c=c.assignedSlot||c.parentNode}else if(c.nodeType==11)c=c.host;else break}function Gh(n,e=!0){let t=n.ownerDocument,i=null,s=null;for(let r=n.parentNode;r&&!(r==t.body||(!e||i)&&s);)if(r.nodeType==1)!s&&r.scrollHeight>r.clientHeight&&(s=r),e&&!i&&r.scrollWidth>r.clientWidth&&(i=r),r=r.assignedSlot||r.parentNode;else if(r.nodeType==11)r=r.host;else break;return{x:i,y:s}}var Xr=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:i}=e;this.set(t,Math.min(e.anchorOffset,t?ct(t):0),i,Math.min(e.focusOffset,i?ct(i):0))}set(e,t,i,s){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=s}},Nt=null;D.safari&&D.safari_version>=26&&(Nt=!1);function _h(n){if(n.setActive)return n.setActive();if(Nt)return n.focus(Nt);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(Nt==null?{get preventScroll(){return Nt={preventScroll:!0},!0}}:void 0),!Nt){Nt=!1;for(let t=0;tMath.max(0,n.document.documentElement.scrollHeight-n.innerHeight-4):n.scrollTop>Math.max(1,n.scrollHeight-n.clientHeight-4)}function Jh(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i>0)return{node:t,offset:i};if(t.nodeType==1&&i>0){if(t.contentEditable=="false")return null;t=t.childNodes[i-1],i=ct(t)}else if(t.parentNode&&!ns(t))i=xt(t),t=t.parentNode;else return null}}function Xh(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i=t){if(l.level==i)return o;(r<0||(s!=0?s<0?l.fromt:e[r].level>l.level))&&(r=o)}}if(r<0)throw new RangeError("Index out of range");return r}};function ec(n,e){if(n.length!=e.length)return!1;for(let t=0;t=0;m-=3)if(Xe[m+1]==-d){let g=Xe[m+2],y=g&2?s:g&4?g&1?r:s:0;y&&(U[f]=U[Xe[m]]=y),l=m;break}}else{if(Xe.length==189)break;Xe[l++]=f,Xe[l++]=u,Xe[l++]=a}else if((p=U[f])==2||p==1){let m=p==s;a=m?0:1;for(let g=l-3;g>=0;g-=3){let y=Xe[g+2];if(y&2)break;if(m)Xe[g+2]|=2;else{if(y&4)break;Xe[g+2]|=4}}}}}function dp(n,e,t,i){for(let s=0,r=i;s<=t.length;s++){let o=s?t[s-1].to:n,l=sa;)p==g&&(p=t[--m].from,g=m?t[m-1].to:n),U[--p]=d;a=c}else r=h,a++}}}function Zr(n,e,t,i,s,r,o){let l=i%2?2:1;if(i%2==s%2)for(let a=e,h=0;aa&&o.push(new He(a,m.from,d));let g=m.direction==Vt!=!(d%2);eo(n,g?i+1:i,s,m.inner,m.from,m.to,o),a=m.to}p=m.to}else{if(p==t||(c?U[p]!=l:U[p]==l))break;p++}u?Zr(n,a,p,i+1,s,u,o):ae;){let c=!0,f=!1;if(!h||a>r[h-1].to){let m=U[a-1];m!=l&&(c=!1,f=m==16)}let u=!c&&l==1?[]:null,d=c?i:i+1,p=a;e:for(;;)if(h&&p==r[h-1].to){if(f)break e;let m=r[--h];if(!c)for(let g=m.from,y=h;;){if(g==e)break e;if(y&&r[y-1].to==g)g=r[--y].from;else{if(U[g-1]==l)break e;break}}if(u)u.push(m);else{m.toU.length;)U[U.length]=256;let i=[],s=e==Vt?0:1;return eo(n,s,s,t,0,n.length,i),i}function tc(n){return[new He(0,n,0)]}var ic="";function mp(n,e,t,i,s){var r;let o=i.head-n.from,l=He.find(e,o,(r=i.bidiLevel)!==null&&r!==void 0?r:-1,i.assoc),a=e[l],h=a.side(s,t);if(o==h){let u=l+=s?1:-1;if(u<0||u>=e.length)return null;a=e[l=u],o=a.side(!s,t),h=a.side(s,t)}let c=te(n.text,o,a.forward(s,t));(ca.to)&&(c=h),ic=n.text.slice(Math.min(o,c),Math.max(o,c));let f=l==(s?e.length-1:0)?null:e[l+(s?1:-1)];return f&&c==h&&f.level+(s?0:1)n.some(e=>e)}),cc=C.define({combine:n=>n.some(e=>e)}),fc=C.define(),zi=class n{constructor(e,t,i,s,r,o=!1){this.range=e,this.y=t,this.x=i,this.yMargin=s,this.xMargin=r,this.isSnapshot=o}map(e){return e.empty?this:new n(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}clip(e){return this.range.to<=e.doc.length?this:new n(b.cursor(e.doc.length),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}},zn=L.define({map:(n,e)=>n.map(e)}),uc=L.define();function re(n,e,t){let i=n.facet(oc);i.length?i[0](e):window.onerror&&window.onerror(String(e),t,void 0,void 0,e)||(t?console.error(t+":",e):console.error(e))}var at=C.define({combine:n=>n.length?n[0]:!0}),yp=0,ai=C.define({combine(n){return n.filter((e,t)=>{for(let i=0;i{let a=[];return o&&a.push(ws.of(h=>{let c=h.plugin(l);return c?o(c):B.none})),r&&a.push(r(l)),a})}static fromClass(e,t){return n.define((i,s)=>new e(i,s),t)}},qi=class{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}get plugin(){return this.spec&&this.spec.plugin}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(i){if(re(t.state,i,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.plugin.create(e,this.spec.arg)}catch(t){re(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(i){re(e.state,i,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}},dc=C.define(),Go=C.define(),ws=C.define(),pc=C.define(),_o=C.define(),Qi=C.define(),mc=C.define();function uh(n,e){let t=n.state.facet(mc);if(!t.length)return t;let i=t.map(r=>r instanceof Function?r(n):r),s=[];return I.spans(i,e.from,e.to,{point(){},span(r,o,l,a){let h=r-e.from,c=o-e.from,f=s;for(let u=l.length-1;u>=0;u--,a--){let d=l[u].spec.bidiIsolate,p;if(d==null&&(d=gp(e.text,h,c)),a>0&&f.length&&(p=f[f.length-1]).to==h&&p.direction==d)p.to=c,f=p.inner;else{let m={from:h,to:c,direction:d,inner:[]};f.push(m),f=m.inner}}}}),s}var gc=C.define();function Yo(n){let e=0,t=0,i=0,s=0;for(let r of n.state.facet(gc)){let o=r(n);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(i=Math.max(i,o.top)),o.bottom!=null&&(s=Math.max(s,o.bottom)))}return{left:e,right:t,top:i,bottom:s}}var Ni=C.define(),Ke=class n{constructor(e,t,i,s){this.fromA=e,this.toA=t,this.fromB=i,this.toB=s}join(e){return new n(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,i=this;for(;t>0;t--){let s=e[t-1];if(!(s.fromA>i.toA)){if(s.toAs.push(new Ke(r,o,l,a))),this.changedRanges=s}static create(e,t,i){return new n(e,t,i)}get viewportChanged(){return(this.flags&4)>0}get viewportMoved(){return(this.flags&8)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&18)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}},bp=[],X=class{constructor(e,t,i=0){this.dom=e,this.length=t,this.flags=i,this.parent=null,e.cmTile=this}get breakAfter(){return this.flags&1}get children(){return bp}isWidget(){return!1}get isHidden(){return!1}isComposite(){return!1}isLine(){return!1}isText(){return!1}isBlock(){return!1}get domAttrs(){return null}sync(e){if(this.flags|=2,this.flags&4){this.flags&=-5;let t=this.domAttrs;t&&tp(this.dom,t)}}toString(){return this.constructor.name+(this.children.length?`(${this.children})`:"")+(this.breakAfter?"#":"")}destroy(){this.parent=null}setDOM(e){this.dom=e,e.cmTile=this}get posAtStart(){return this.parent?this.parent.posBefore(this):0}get posAtEnd(){return this.posAtStart+this.length}posBefore(e,t=this.posAtStart){let i=t;for(let s of this.children){if(s==e)return i;i+=s.length+s.breakAfter}throw new RangeError("Invalid child in posBefore")}posAfter(e){return this.posBefore(e)+e.length}covers(e){return!0}coordsIn(e,t,i){return null}domPosFor(e,t){let i=xt(this.dom),s=this.length?e>0:t>0;return new Qe(this.parent.dom,i+(s?1:0),e==0||e==this.length)}markDirty(e){this.flags&=-3,e&&(this.flags|=4),this.parent&&this.parent.flags&2&&this.parent.markDirty(!1)}get overrideDOMText(){return null}get root(){for(let e=this;e;e=e.parent)if(e instanceof mi)return e;return null}static get(e){return e.cmTile}},pi=class extends X{constructor(e){super(e,0),this._children=[]}isComposite(){return!0}get children(){return this._children}get lastChild(){return this.children.length?this.children[this.children.length-1]:null}append(e){this.children.push(e),e.parent=this}sync(e){if(this.flags&2)return;super.sync(e);let t=this.dom,i=null,s,r=e?.node==t?e:null,o=0;for(let l of this.children){if(l.sync(e),o+=l.length+l.breakAfter,s=i?i.nextSibling:t.firstChild,r&&s!=l.dom&&(r.written=!0),l.dom.parentNode==t)for(;s&&s!=l.dom;)s=dh(s);else t.insertBefore(l.dom,s);i=l.dom}for(s=i?i.nextSibling:t.firstChild,r&&s&&(r.written=!0);s;)s=dh(s);this.length=o}};function dh(n){let e=n.nextSibling;return n.parentNode.removeChild(n),e}var mi=class extends pi{constructor(e,t){super(t),this.view=e}owns(e){for(;e;e=e.parent)if(e==this)return!0;return!1}isBlock(){return!0}nearest(e){for(;;){if(!e)return null;let t=X.get(e);if(t&&this.owns(t))return t;e=e.parentNode}}blockTiles(e){for(let t=[],i=this,s=0,r=0;;)if(s==i.children.length){if(!t.length)return;i=i.parent,i.breakAfter&&r++,s=t.pop()}else{let o=i.children[s++];if(o instanceof ht)t.push(s),i=o,s=0;else{let l=r+o.length,a=e(o,r);if(a!==void 0)return a;r=l+o.breakAfter}}}resolveBlock(e,t){let i,s=-1,r,o=-1;if(this.blockTiles((l,a)=>{let h=a+l.length;if(e>=a&&e<=h){if(l.isWidget()&&t>=-1&&t<=1){if(l.flags&32)return!0;l.flags&16&&(i=void 0)}(ae||e==a&&(t>1?l.length:l.covers(-1)))&&(!r||!l.isWidget()&&r.isWidget())&&(r=l,o=e-a)}}),!i&&!r)throw new Error("No tile at position "+e);return i&&t<0||!r?{tile:i,offset:s}:{tile:r,offset:o}}},ht=class n extends pi{constructor(e,t){super(e),this.wrapper=t}isBlock(){return!0}covers(e){return this.children.length?e<0?this.children[0].covers(-1):this.lastChild.covers(1):!1}get domAttrs(){return this.wrapper.attributes}static of(e,t){let i=new n(t||document.createElement(e.tagName),e);return t||(i.flags|=4),i}},gi=class n extends pi{constructor(e,t){super(e),this.attrs=t}isLine(){return!0}static start(e,t,i){let s=new n(t||document.createElement("div"),e);return(!t||!i)&&(s.flags|=4),s}get domAttrs(){return this.attrs}resolveInline(e,t,i){let s=null,r=-1,o=null,l=-1;function a(c,f){for(let u=0,d=0;u=f&&(p.isComposite()?a(p,f-d):(!o||o.isHidden&&(t>0&&!(o.flags&32)||i&&wp(o,p)))&&(m>f||p.flags&32)?(o=p,l=f-d):(ds&&(e=s);let r=e,o=e,l=0;e==0&&t<0||e==s&&t>=0?D.chrome||D.gecko||(e?(r--,l=1):o=0)?0:a.length-1];return D.safari&&!l&&h.width==0&&(h=Array.prototype.find.call(a,c=>c.width)||h),i==null?h:ss(h,(l?l>0:t<0)==i)}static of(e,t){let i=new n(t||document.createTextNode(e),e);return t||(i.flags|=2),i}},zt=class n extends X{constructor(e,t,i,s){super(e,t,s),this.widget=i}isWidget(){return!0}get isHidden(){return this.widget.isHidden}covers(e){return this.flags&48?!1:(this.flags&(e<0?64:128))>0}coordsIn(e,t){return this.coordsInWidget(e,t,!1)}coordsInWidget(e,t,i){let s=this.widget.coordsAt(this.dom,e,t);if(s)return s;if(i)return ss(this.dom.getBoundingClientRect(),this.length?e==0:t<=0);{let r=this.dom.getClientRects(),o=null;if(!r.length)return null;let l=this.flags&16?!0:this.flags&32?!1:e>0;for(let a=l?r.length-1:0;o=r[a],!(e>0?a==0:a==r.length-1||o.top0==i)}},io=class{constructor(e){this.index=0,this.beforeBreak=!1,this.parents=[],this.tile=e}advance(e,t,i){let{tile:s,index:r,beforeBreak:o,parents:l}=this;for(;e||t>0;)if(s.isComposite())if(o){if(!e)break;i&&i.break(),e--,o=!1}else if(r==s.children.length){if(!e&&!l.length)break;i&&i.leave(s),o=!!s.breakAfter,{tile:s,index:r}=l.pop(),r++}else{let a=s.children[r],h=a.breakAfter;(t>0?a.length<=e:a.length=0;l--){let a=t.marks[l],h=s.lastChild;if(h instanceof Ce&&h.mark.eq(a.mark))h.dom!=a.dom&&h.setDOM(Hr(a.dom)),s=h;else{if(this.cache.reused.get(a)){let f=X.get(a.dom);f&&f.setDOM(Hr(a.dom))}let c=Ce.of(a.mark,a.dom);s.append(c),s=c}this.cache.reused.set(a,2)}let r=X.get(e.text);r&&this.cache.reused.set(r,2);let o=new Ft(e.text,e.text.nodeValue);o.flags|=8,this.pos=e.range.toB,s.append(o)}addInlineWidget(e,t,i){let s=this.afterWidget&&e.flags&48&&(this.afterWidget.flags&48)==(e.flags&48);s||this.flushBuffer();let r=this.ensureMarks(t,i);!s&&!(e.flags&16)&&r.append(this.getBuffer(1)),r.append(e),this.pos+=e.length,this.afterWidget=e}addMark(e,t,i){this.flushBuffer(),this.ensureMarks(t,i).append(e),this.pos+=e.length,this.afterWidget=null}addBlockWidget(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}continueWidget(e){let t=this.afterWidget||this.lastBlock;t.length+=e,this.pos+=e}addLineStart(e,t){var i;e||(e=yc);let s=gi.start(e,t||((i=this.cache.find(gi))===null||i===void 0?void 0:i.dom),!!t);this.getBlockPos().append(this.lastBlock=this.curLine=s)}addLine(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}addBreak(){this.lastBlock.flags|=1,this.endLine(),this.pos++}addLineStartIfNotCovered(e){this.blockPosCovered()||this.addLineStart(e)}ensureLine(e){this.curLine||this.addLineStart(e)}ensureMarks(e,t){var i;let s=this.curLine;for(let r=e.length-1;r>=0;r--){let o=e[r],l;if(t>0&&(l=s.lastChild)&&l instanceof Ce&&l.mark.eq(o))s=l,t--;else{let a=Ce.of(o,(i=this.cache.find(Ce,h=>h.mark.eq(o)))===null||i===void 0?void 0:i.dom);s.append(a),s=a,t=0}}return s}endLine(){if(this.curLine){this.flushBuffer();let e=this.curLine.lastChild;(!e||!ph(this.curLine,!1)||e.dom.nodeName!="BR"&&e.isWidget()&&!(D.ios&&ph(this.curLine,!0)))&&this.curLine.append(this.cache.findWidget(Wr,0,32)||new zt(Wr.toDOM(),0,Wr,32)),this.curLine=this.afterWidget=null}}updateBlockWrappers(){this.wrapperPos>this.pos+1e4&&(this.blockWrappers.goto(this.pos),this.wrappers.length=0);for(let e=this.wrappers.length-1;e>=0;e--)this.wrappers[e].to=this.pos){let t=e.rank*102+e.value.rank,i=new no(e.from,e.to,e.value,t),s=this.wrappers.length;for(;s>0&&(this.wrappers[s-1].rank-i.rank||this.wrappers[s-1].to-i.to)<0;)s--;this.wrappers.splice(s,0,i)}this.wrapperPos=this.pos}getBlockPos(){var e;this.updateBlockWrappers();let t=this.root;for(let i of this.wrappers){let s=t.lastChild;if(i.fromo.wrapper.eq(i.wrapper)))===null||e===void 0?void 0:e.dom);t.append(r),t=r}}return t}blockPosCovered(){let e=this.lastBlock;return e!=null&&!e.breakAfter&&(!e.isWidget()||(e.flags&160)>0)}getBuffer(e){let t=2|(e<0?16:32),i=this.cache.find(yi,void 0,1);return i&&(i.flags=t),i||new yi(t)}flushBuffer(){this.afterWidget&&!(this.afterWidget.flags&32)&&(this.afterWidget.parent.append(this.getBuffer(-1)),this.afterWidget=null)}},ro=class{constructor(e){this.skipCount=0,this.text="",this.textOff=0,this.cursor=e.iter()}skip(e){this.textOff+e<=this.text.length?this.textOff+=e:(this.skipCount+=e-(this.text.length-this.textOff),this.text="",this.textOff=0)}next(e){if(this.textOff==this.text.length){let{value:s,lineBreak:r,done:o}=this.cursor.next(this.skipCount);if(this.skipCount=0,o)throw new Error("Ran out of text content when drawing inline views");this.text=s;let l=this.textOff=Math.min(e,s.length);return r?null:s.slice(0,l)}let t=Math.min(this.text.length,this.textOff+e),i=this.text.slice(this.textOff,t);return this.textOff=t,i}},os=[zt,gi,Ft,Ce,yi,ht,mi];for(let n=0;n[]),this.index=os.map(()=>0),this.reused=new Map}add(e){let t=e.constructor.bucket,i=this.buckets[t];i.length<6?i.push(e):i[this.index[t]=(this.index[t]+1)%6]=e}find(e,t,i=2){let s=e.bucket,r=this.buckets[s],o=this.index[s];for(let l=0;l{if(this.cache.add(o),o.isComposite())return!1},enter:o=>this.cache.add(o),leave:()=>{},break:()=>{}}}run(e,t){let i=t&&this.getCompositionContext(t.text);for(let s=0,r=0,o=0;;){let l=os){let h=a-s;this.preserve(h,!o,!l),s=a,r+=h}if(!l)break;t&&l.fromA<=t.range.fromA&&l.toA>=t.range.toA?(this.forward(l.fromA,t.range.fromA,t.range.fromA{if(o.isWidget())if(this.openWidget)this.builder.continueWidget(a-l);else{let h=a>0||l{o.isLine()?this.builder.addLineStart(o.attrs,this.cache.maybeReuse(o)):(this.cache.add(o),o instanceof Ce&&s.unshift(o.mark)),this.openWidget=!1},leave:o=>{o.isLine()?s.length&&(s.length=r=0):o instanceof Ce&&(s.shift(),r=Math.min(r,s.length))},break:()=>{this.builder.addBreak(),this.openWidget=!1}}),this.text.skip(e)}emit(e,t){let i=null,s=this.builder,r=-1,o=I.spans(this.decorations,e,t,{point:(l,a,h,c,f,u)=>{if(h instanceof Wt){if(this.disallowBlockEffectsFor[u]){if(h.block)throw new RangeError("Block decorations may not be specified via plugins");if(a>this.view.state.doc.lineAt(l).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}if(r=c.length,f>c.length)s.continueWidget(a-l);else{let d=h.widget||(h.block?wt.block:wt.inline),p=vp(h),m=this.cache.findWidget(d,a-l,p)||zt.of(d,this.view,a-l,p);h.block?(h.startSide>0&&s.addLineStartIfNotCovered(i),s.addBlockWidget(m)):(s.ensureLine(i),s.addInlineWidget(m,c,f))}i=null}else i=kp(i,h);a>l&&this.text.skip(a-l)},span:(l,a,h,c)=>{for(let f=l;f-1&&(this.openWidget=o>r),this.openWidget||s.addLineStartIfNotCovered(i),this.openMarks=o}forward(e,t,i=1){t-e<=10?this.old.advance(t-e,i,this.reuseWalker):(this.old.advance(5,-1,this.reuseWalker),this.old.advance(t-e-10,-1),this.old.advance(5,i,this.reuseWalker))}getCompositionContext(e){let t=[],i=null;for(let s=e.parentNode;;s=s.parentNode){let r=X.get(s);if(s==this.view.contentDOM)break;r instanceof Ce?t.push(r):r?.isLine()?i=r:r instanceof ht||(s.nodeName=="DIV"&&!i&&s!=this.view.contentDOM?i=new gi(s,yc):i||t.push(Ce.of(new _i({tagName:s.nodeName.toLowerCase(),attributes:ip(s)}),s)))}return{line:i,marks:t}}};function ph(n,e){let t=i=>{for(let s of i.children)if((e?s.isText():s.length)||t(s))return!0;return!1};return t(n)}function vp(n){let e=n.isReplace?(n.startSide<0?64:0)|(n.endSide>0?128:0):n.startSide>0?32:16;return n.block&&(e|=256),e}var yc={class:"cm-line"};function kp(n,e){let t=e.spec.attributes,i=e.spec.class;return!t&&!i||(n||(n={class:"cm-line"}),t&&qo(t,n),i&&(n.class+=" "+i)),n}function Sp(n){let e=[];for(let t=n.parents.length;t>1;t--){let i=t==n.parents.length?n.tile:n.parents[t].tile;i instanceof Ce&&e.push(i.mark)}return e}function Hr(n){let e=X.get(n);return e&&e.setDOM(n.cloneNode()),n}var wt=class extends xe{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}};wt.inline=new wt("span");wt.block=new wt("div");var Wr=new class extends xe{toDOM(){return document.createElement("br")}get isHidden(){return!0}get editable(){return!0}},ls=class{constructor(e){this.view=e,this.decorations=[],this.blockWrappers=[],this.dynamicDecorationMap=[!1],this.domChanged=null,this.hasComposition=null,this.editContextFormatting=B.none,this.lastCompositionAfterCursor=!1,this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.updateDeco(),this.tile=new mi(e,e.contentDOM),this.updateInner([new Ke(0,0,0,e.state.doc.length)],null)}update(e){var t;let i=e.changedRanges;this.minWidth>0&&i.length&&(i.every(({fromA:c,toA:f})=>fthis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.updateEditContextFormatting(e);let s=-1;this.view.inputState.composing>=0&&!this.view.observer.editContext&&(!((t=this.domChanged)===null||t===void 0)&&t.newSel?s=this.domChanged.newSel.head:!Lp(e.changes,this.hasComposition)&&!e.selectionSet&&(s=e.state.selection.main.head));let r=s>-1?Ap(this.view,e.changes,s):null;if(this.domChanged=null,this.hasComposition){let{from:c,to:f}=this.hasComposition;i=new Ke(c,f,e.changes.mapPos(c,-1),e.changes.mapPos(f,1)).addToSet(i.slice())}this.hasComposition=r?{from:r.range.fromB,to:r.range.toB}:null,(D.ie||D.chrome)&&!r&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let o=this.decorations,l=this.blockWrappers;this.updateDeco();let a=Dp(o,this.decorations,e.changes);a.length&&(i=Ke.extendWithRanges(i,a));let h=Op(l,this.blockWrappers,e.changes);return h.length&&(i=Ke.extendWithRanges(i,h)),r&&!i.some(c=>c.fromA<=r.range.fromA&&c.toA>=r.range.toA)&&(i=r.range.addToSet(i.slice())),this.tile.flags&2&&i.length==0?!1:(this.updateInner(i,r),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t){this.view.viewState.mustMeasureContent=!0;let{observer:i}=this.view;i.ignore(()=>{if(t||e.length){let o=this.tile,l=new lo(this.view,o,this.blockWrappers,this.decorations,this.dynamicDecorationMap);t&&X.get(t.text)&&l.cache.reused.set(X.get(t.text),2),this.tile=l.run(e,t),ao(o,l.cache.reused)}this.tile.dom.style.height=this.view.viewState.contentHeight/this.view.scaleY+"px",this.tile.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let r=D.chrome||D.ios?{node:i.selectionRange.focusNode,written:!1}:void 0;this.tile.sync(r),r&&(r.written||i.selectionRange.focusNode!=r.node||!this.tile.dom.contains(r.node))&&(this.forceSelection=!0),this.tile.dom.style.height=""});let s=[];if(this.view.viewport.from||this.view.viewport.to-1)&&Wi(i,this.view.observer.selectionRange)&&!(s&&i.contains(s));if(!(r||t||o))return;let l=this.forceSelection;this.forceSelection=!1;let a=this.view.state.selection.main,h,c;if(a.empty?c=h=this.inlineDOMNearPos(a.anchor,a.assoc||1):(c=this.inlineDOMNearPos(a.head,a.head==a.from?1:-1),h=this.inlineDOMNearPos(a.anchor,a.anchor==a.from?1:-1)),D.gecko&&a.empty&&!this.hasComposition&&Cp(h)){let u=document.createTextNode("");this.view.observer.ignore(()=>h.node.insertBefore(u,h.node.childNodes[h.offset]||null)),h=c=new Qe(u,0),l=!0}let f=this.view.observer.selectionRange;(l||!f.focusNode||(!Vi(h.node,h.offset,f.anchorNode,f.anchorOffset)||!Vi(c.node,c.offset,f.focusNode,f.focusOffset))&&!this.suppressWidgetCursorChange(f,a))&&(this.view.observer.ignore(()=>{D.android&&D.chrome&&i.contains(f.focusNode)&&Bp(f.focusNode,i)&&(i.blur(),i.focus({preventScroll:!0}));let u=Ji(this.view.root);if(u)if(a.empty){if(D.gecko){let d=Mp(h.node,h.offset);if(d&&d!=3){let p=(d==1?Jh:Xh)(h.node,h.offset);p&&(h=new Qe(p.node,p.offset))}}u.collapse(h.node,h.offset),a.bidiLevel!=null&&u.caretBidiLevel!==void 0&&(u.caretBidiLevel=a.bidiLevel)}else if(u.extend){u.collapse(h.node,h.offset);try{u.extend(c.node,c.offset)}catch{}}else{let d=document.createRange();a.anchor>a.head&&([h,c]=[c,h]),d.setEnd(c.node,c.offset),d.setStart(h.node,h.offset),u.removeAllRanges(),u.addRange(d)}o&&this.view.root.activeElement==i&&(i.blur(),s&&s.focus())}),this.view.observer.setSelectionRange(h,c)),this.impreciseAnchor=h.precise?null:new Qe(f.anchorNode,f.anchorOffset),this.impreciseHead=c.precise?null:new Qe(f.focusNode,f.focusOffset)}suppressWidgetCursorChange(e,t){return this.hasComposition&&t.empty&&Vi(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset)&&this.posFromDOM(e.focusNode,e.focusOffset)==t.head}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,i=Ji(e.root),{anchorNode:s,anchorOffset:r}=e.observer.selectionRange;if(!i||!t.empty||!t.assoc||!i.modify)return;let o=this.lineAt(t.head,t.assoc);if(!o)return;let l=o.posAtStart;if(t.head==l||t.head==l+o.length)return;let a=this.coordsAt(t.head,-1),h=this.coordsAt(t.head,1);if(!a||!h||a.bottom>h.top)return;let c=this.domAtPos(t.head+t.assoc,t.assoc);i.collapse(c.node,c.offset),i.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let f=e.observer.selectionRange;e.docView.posFromDOM(f.anchorNode,f.anchorOffset)!=t.from&&i.collapse(s,r)}posFromDOM(e,t){let i=this.tile.nearest(e);if(!i)return this.tile.dom.compareDocumentPosition(e)&2?0:this.view.state.doc.length;let s=i.posAtStart;if(i.isComposite()){let r;if(e==i.dom)r=i.dom.childNodes[t];else{let o=ct(e)==0?0:t==0?-1:1;for(;;){let l=e.parentNode;if(l==i.dom)break;o==0&&l.firstChild!=l.lastChild&&(e==l.firstChild?o=-1:o=1),e=l}o<0?r=e:r=e.nextSibling}if(r==i.dom.firstChild)return s;for(;r&&!X.get(r);)r=r.nextSibling;if(!r)return s+i.length;for(let o=0,l=s;;o++){let a=i.children[o];if(a.dom==r)return l;l+=a.length+a.breakAfter}}else return i.isText()?e==i.dom?s+t:s+(t?i.length:0):s}domAtPos(e,t){let{tile:i,offset:s}=this.tile.resolveBlock(e,t);return i.isWidget()?i.domPosFor(s,t):i.domIn(s,t)}inlineDOMNearPos(e,t){let i,s=-1,r=!1,o,l=-1,a=!1;return this.tile.blockTiles((h,c)=>{if(h.isWidget()){if(h.flags&32&&c>=e)return!0;h.flags&16&&(r=!0)}else{let f=c+h.length;if(c<=e&&(i=h,s=e-c,r=f=e&&!o&&(o=h,l=e-c,a=c>e),c>e&&o)return!0}}),!i&&!o?this.domAtPos(e,t):(r&&o?i=null:a&&i&&(o=null),i&&t<0||!o?i.domIn(s,t):o.domIn(l,t))}coordsAt(e,t,i){let{tile:s,offset:r}=this.tile.resolveBlock(e,t);return s.isWidget()?s.widget instanceof Ki?null:s.coordsInWidget(r,t,!0):s.coordsIn(r,t,i)}lineAt(e,t){let{tile:i}=this.tile.resolveBlock(e,t);return i.isLine()?i:null}coordsForChar(e){let{tile:t,offset:i}=this.tile.resolveBlock(e,1);if(!t.isLine())return null;function s(r,o){if(r.isComposite())for(let l of r.children){if(l.length>=o){let a=s(l,o);if(a)return a}if(o-=l.length,o<0)break}else if(r.isText()&&oMath.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,l=-1,a=this.view.textDirection==V.LTR,h=0,c=(f,u,d)=>{for(let p=0;ps);p++){let m=f.children[p],g=u+m.length,y=m.dom.getBoundingClientRect(),{height:x}=y;if(d&&!p&&(h+=y.top-d.top),m instanceof ht)g>i&&c(m,u,y);else if(u>=i&&(h>0&&t.push(-h),t.push(x+h),h=0,o)){let w=m.dom.lastChild,O=w?Jn(w):[];if(O.length){let v=O[O.length-1],k=a?v.right-y.left:y.right-v.left;k>l&&(l=k,this.minWidth=r,this.minWidthFrom=u,this.minWidthTo=g)}}d&&p==f.children.length-1&&(h+=d.bottom-y.bottom),u=g+m.breakAfter}};return c(this.tile,0,null),t}textDirectionAt(e){let{tile:t}=this.tile.resolveBlock(e,1);return getComputedStyle(t.dom).direction=="rtl"?V.RTL:V.LTR}measureTextSize(){let e=this.tile.blockTiles(o=>{if(o.isLine()&&o.children.length&&o.length<=20){let l=0,a;for(let h of o.children){if(!h.isText()||/[^ -~]/.test(h.text))return;let c=Jn(h.dom);if(c.length!=1)return;l+=c[0].width,a=c[0].height}if(l)return{lineHeight:o.dom.getBoundingClientRect().height,charWidth:l/o.length,textHeight:a}}});if(e)return e;let t=document.createElement("div"),i,s,r;return t.className="cm-line",t.style.width="99999px",t.style.position="absolute",t.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.tile.dom.appendChild(t);let o=Jn(t.firstChild)[0];i=t.getBoundingClientRect().height,s=o&&o.width?o.width/27:7,r=o&&o.height?o.height:i,t.remove()}),{lineHeight:i,charWidth:s,textHeight:r}}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let i=0,s=0;;s++){let r=s==t.viewports.length?null:t.viewports[s],o=r?r.from-1:this.view.state.doc.length;if(o>i){let l=(t.lineBlockAt(o).bottom-t.lineBlockAt(i).top)/this.view.scaleY;e.push(B.replace({widget:new Ki(l),block:!0,inclusive:!0,isBlockGap:!0}).range(i,o))}if(!r)break;i=r.to+1}return B.set(e)}updateDeco(){let e=1,t=this.view.state.facet(ws).map(r=>(this.dynamicDecorationMap[e++]=typeof r=="function")?r(this.view):r),i=!1,s=this.view.state.facet(_o).map((r,o)=>{let l=typeof r=="function";return l&&(i=!0),l?r(this.view):r});for(s.length&&(this.dynamicDecorationMap[e++]=i,t.push(I.join(s))),this.decorations=[this.editContextFormatting,...t,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco];etypeof r=="function"?r(this.view):r)}scrollIntoView(e){if(e.isSnapshot){let h=this.view.viewState.lineBlockAt(e.range.head);this.view.scrollDOM.scrollTop=h.top-e.yMargin,this.view.scrollDOM.scrollLeft=e.xMargin;return}for(let h of this.view.state.facet(fc))try{if(h(this.view,e.range,e))return!0}catch(c){re(this.view.state,c,"scroll handler")}let{range:t}=e,i=this.coordsAt(t.head,t.assoc||(t.head>t.anchor?-1:1)),s;if(!i)return;!t.empty&&(s=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(i={left:Math.min(i.left,s.left),top:Math.min(i.top,s.top),right:Math.max(i.right,s.right),bottom:Math.max(i.bottom,s.bottom)});let r=Yo(this.view),o={left:i.left-r.left,top:i.top-r.top,right:i.right+r.right,bottom:i.bottom+r.bottom},{offsetWidth:l,offsetHeight:a}=this.view.scrollDOM;if(rp(this.view.scrollDOM,o,t.head1&&(i.top>window.pageYOffset+window.visualViewport.offsetTop+window.visualViewport.height||i.bottomi.isWidget()||i.children.some(t);return t(this.tile.resolveBlock(e,1).tile)}destroy(){ao(this.tile)}};function ao(n,e){let t=e?.get(n);if(t!=1){t==null&&n.destroy();for(let i of n.children)ao(i,e)}}function Cp(n){return n.node.nodeType==1&&n.node.firstChild&&(n.offset==0||n.node.childNodes[n.offset-1].contentEditable=="false")&&(n.offset==n.node.childNodes.length||n.node.childNodes[n.offset].contentEditable=="false")}function bc(n,e){let t=n.observer.selectionRange;if(!t.focusNode)return null;let i=Jh(t.focusNode,t.focusOffset),s=Xh(t.focusNode,t.focusOffset),r=i||s;if(s&&i&&s.node!=i.node){let l=X.get(s.node);if(!l||l.isText()&&l.text!=s.node.nodeValue)r=s;else if(n.docView.lastCompositionAfterCursor){let a=X.get(i.node);!a||a.isText()&&a.text!=i.node.nodeValue||(r=s)}}if(n.docView.lastCompositionAfterCursor=r!=i,!r)return null;let o=e-r.offset;return{from:o,to:o+r.node.nodeValue.length,node:r.node}}function Ap(n,e,t){let i=bc(n,t);if(!i)return null;let{node:s,from:r,to:o}=i,l=s.nodeValue;if(/[\n\r]/.test(l)||n.state.doc.sliceString(i.from,i.to)!=l)return null;let a=e.invertedDesc;return{range:new Ke(a.mapPos(r),a.mapPos(o),r,o),text:s}}function Mp(n,e){return n.nodeType!=1?0:(e&&n.childNodes[e-1].contentEditable=="false"?1:0)|(e{ie.from&&(t=!0)}),t}var Ki=class extends xe{constructor(e){super(),this.height=e}toDOM(){let e=document.createElement("div");return e.className="cm-gap",this.updateDOM(e),e}eq(e){return e.height==this.height}updateDOM(e){return e.style.height=this.height+"px",!0}get editable(){return!0}get estimatedHeight(){return this.height}ignoreEvent(){return!1}};function Ep(n,e,t=1){let i=n.charCategorizer(e),s=n.doc.lineAt(e),r=e-s.from;if(s.length==0)return b.cursor(e);r==0?t=1:r==s.length&&(t=-1);let o=r,l=r;t<0?o=te(s.text,r,!1):l=te(s.text,r);let a=i(s.text.slice(o,l));for(;o>0;){let h=te(s.text,o,!1);if(i(s.text.slice(h,o))!=a)break;o=h}for(;ln.defaultLineHeight*1.5){let l=n.viewState.heightOracle.textHeight,a=Math.floor((s-t.top-(n.defaultLineHeight-l)*.5)/l);r+=a*n.viewState.heightOracle.lineLength}let o=n.state.sliceDoc(t.from,t.to);return t.from+Wn(o,r,n.state.tabSize)}function co(n,e,t){let i=n.lineBlockAt(e);if(Array.isArray(i.type)){let s;for(let r of i.type){if(r.from>e)break;if(!(r.toe)return r;(!s||r.type==fe.Text&&(s.type!=r.type||(t<0?r.frome)))&&(s=r)}}return s||i}return i}function Pp(n,e,t,i){let s=co(n,e.head,e.assoc||-1),r=!i||s.type!=fe.Text||!(n.lineWrapping||s.widgetLineBreaks)?null:n.coordsAtPos(e.assoc<0&&e.head>s.from?e.head-1:e.head);if(r){let o=n.dom.getBoundingClientRect(),l=n.textDirectionAt(s.from),a=n.posAtCoords({x:t==(l==V.LTR)?o.right-1:o.left+1,y:(r.top+r.bottom)/2});if(a!=null)return b.cursor(a,t?-1:1)}return b.cursor(t?s.to:s.from,t?-1:1)}function mh(n,e,t,i){let s=n.state.doc.lineAt(e.head),r=n.bidiSpans(s),o=n.textDirectionAt(s.from);for(let l=e,a=null;;){let h=mp(s,r,o,l,t),c=ic;if(!h){if(s.number==(t?n.state.doc.lines:1))return l;c=` +`,s=n.state.doc.line(s.number+(t?1:-1)),r=n.bidiSpans(s),h=n.visualLineSide(s,!t)}if(a){if(!a(c))return l}else{if(!i)return h;a=i(c)}l=h}}function Ip(n,e,t){let i=n.state.charCategorizer(e),s=i(t);return r=>{let o=i(r);return s==K.Space&&(s=o),s==o}}function Np(n,e,t,i){let s=e.head,r=t?1:-1;if(s==(t?n.state.doc.length:0))return b.cursor(s,e.assoc);let o=e.goalColumn,l,a=n.contentDOM.getBoundingClientRect(),h=n.coordsAtPos(s,e.assoc||((e.empty?t:e.head==e.from)?1:-1)),c=n.documentTop;if(h)o==null&&(o=h.left-a.left),l=r<0?h.top:h.bottom;else{let p=n.viewState.lineBlockAt(s);o==null&&(o=Math.min(a.right-a.left,n.defaultCharacterWidth*(s-p.from))),l=(r<0?p.top:p.bottom)+c}let f=a.left+o,u=n.viewState.heightOracle.textHeight>>1,d=i??u;for(let p=0;;p+=u){let m=l+(d+p)*r,g=fo(n,{x:f,y:m},!1,r);if(t?m>a.bottom:ml:x{if(e>r&&es(n)),t.from,e.head>t.from?-1:1);return i==t.from?t:b.cursor(i,in.viewState.docHeight)return new Fe(n.state.doc.length,-1);if(h=n.elementAtHeight(a),i==null)break;if(h.type==fe.Text){if(i<0?h.ton.viewport.to)break;let u=n.docView.coordsAt(i<0?h.from:h.to,i>0?-1:1);if(u&&(i<0?u.top<=a+r:u.bottom>=a+r))break}let f=n.viewState.heightOracle.textHeight/2;a=i>0?h.bottom+f:h.top-f}if(n.viewport.from>=h.to||n.viewport.to<=h.from){if(t)return null;if(h.type==fe.Text){let f=Rp(n,s,h,o,l);return new Fe(f,f==h.from?1:-1)}}if(h.type!=fe.Text)return a<(h.top+h.bottom)/2?new Fe(h.from,1):new Fe(h.to,-1);let c=n.docView.lineAt(h.from,2);return(!c||c.length!=h.length)&&(c=n.docView.lineAt(h.from,-2)),new uo(n,o,l,n.textDirectionAt(h.from)).scanTile(c,h.from)}var uo=class{constructor(e,t,i,s){this.view=e,this.x=t,this.y=i,this.baseDir=s,this.line=null,this.spans=null}bidiSpansAt(e){return(!this.line||this.line.from>e||this.line.to1||i.length&&(i[0].level!=this.baseDir||i[0].to+s.from>1;t:if(o.has(m)){let y=s+Math.floor(Math.random()*p);for(let x=0;x1)){if(x.bottomthis.y)(!h||h.top>x.top)&&(h=x),w=-1;else{let O=x.left>this.x?this.x-x.left:x.right(p+p+m)/3)return this.y=a.bottom-1,this.scan(e,t,!0);if(h&&h.top<(p+m+m)/3)return this.y=h.top+1,this.scan(e,t,!0)}let d=(l?this.dirAt(e[c],1):this.baseDir)==V.LTR;return{i:c,after:this.x>(u.left+u.right)/2==d}}scanText(e,t){let i=[];for(let r=0;r{let o=i[r]-t,l=i[r+1]-t;return Xi(e.dom,o,l).getClientRects()});return s.after?new Fe(i[s.i+1],-1):new Fe(i[s.i],1)}scanTile(e,t){if(!e.length)return new Fe(t,1);if(e.children.length==1){let l=e.children[0];if(l.isText())return this.scanText(l,t);if(l.isComposite())return this.scanTile(l,t)}let i=[t];for(let l=0,a=t;l{let a=e.children[l];return a.flags&48?null:(a.dom.nodeType==1?a.dom:Xi(a.dom,0,a.length)).getClientRects()}),r=e.children[s.i],o=i[s.i];return r.isText()?this.scanText(r,o):r.isComposite()?this.scanTile(r,o):s.after?new Fe(i[s.i+1],-1):new Fe(o,1)}},li="\uFFFF",po=class{constructor(e,t){this.points=e,this.view=t,this.text="",this.lineSeparator=t.state.facet(j.lineSeparator)}append(e){this.text+=e}lineBreak(){this.text+=li}readRange(e,t){if(!e)return this;let i=e.parentNode;for(let s=e;;){this.findPointBefore(i,s);let r=this.text.length;this.readNode(s);let o=X.get(s),l=s.nextSibling;if(l==t){o?.breakAfter&&!l&&i!=this.view.contentDOM&&this.lineBreak();break}let a=X.get(l);(o&&a?o.breakAfter:(o?o.breakAfter:ns(s))||ns(l)&&(s.nodeName!="BR"||o?.isWidget())&&this.text.length>r)&&!Hp(l,t)&&this.lineBreak(),s=l}return this.findPointBefore(i,t),this}readTextNode(e){let t=e.nodeValue;for(let i of this.points)i.node==e&&(i.pos=this.text.length+Math.min(i.offset,t.length));for(let i=0,s=this.lineSeparator?null:/\r\n?|\n/g;;){let r=-1,o=1,l;if(this.lineSeparator?(r=t.indexOf(this.lineSeparator,i),o=this.lineSeparator.length):(l=s.exec(t))&&(r=l.index,o=l[0].length),this.append(t.slice(i,r<0?t.length:r)),r<0)break;if(this.lineBreak(),o>1)for(let a of this.points)a.node==e&&a.pos>this.text.length&&(a.pos-=o-1);i=r+o}}readNode(e){let t=X.get(e),i=t&&t.overrideDOMText;if(i!=null){this.findPointInside(e,i.length);for(let s=i.iter();!s.next().done;)s.lineBreak?this.lineBreak():this.append(s.value)}else e.nodeType==3?this.readTextNode(e):e.nodeName=="BR"?e.nextSibling&&this.lineBreak():e.nodeType==1&&this.readRange(e.firstChild,null)}findPointBefore(e,t){for(let i of this.points)i.node==e&&e.childNodes[i.offset]==t&&(i.pos=this.text.length)}findPointInside(e,t){for(let i of this.points)(e.nodeType==3?i.node==e:e.contains(i.node))&&(i.pos=this.text.length+(Fp(e,i.node,i.offset)?t:0))}};function Fp(n,e,t){for(;;){if(!e||t-1;let{impreciseHead:r,impreciseAnchor:o}=e.docView,l=e.state.selection;if(e.state.readOnly&&t>-1)this.newSel=null;else if(t>-1&&(this.bounds=wc(e.docView.tile,t,i,0))){let a=r||o?[]:Vp(e),h=new po(a,e);h.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=h.text,this.newSel=zp(a,this.bounds.from)}else{let a=e.observer.selectionRange,h=r&&r.node==a.focusNode&&r.offset==a.focusOffset||!Jr(e.contentDOM,a.focusNode)?l.main.head:e.docView.posFromDOM(a.focusNode,a.focusOffset),c=o&&o.node==a.anchorNode&&o.offset==a.anchorOffset||!Jr(e.contentDOM,a.anchorNode)?l.main.anchor:e.docView.posFromDOM(a.anchorNode,a.anchorOffset),f=e.viewport;if((D.ios||D.chrome)&&h!=c&&Math.min(h,c)<=l.main.from&&Math.max(h,c)>=l.main.to&&(f.from>0||f.to-1&&l.ranges.length>1)this.newSel=l.replaceRange(b.range(c,h));else if(e.lineWrapping&&c==h&&!(l.main.empty&&l.main.head==h)&&e.inputState.lastTouchTime>Date.now()-100){let u=e.coordsAtPos(h,-1),d=0;u&&(d=e.inputState.lastTouchY<=u.bottom?-1:1),this.newSel=b.create([b.cursor(h,d)])}else this.newSel=b.single(c,h)}}};function wc(n,e,t,i){if(n.isComposite()){let s=-1,r=-1,o=-1,l=-1;for(let a=0,h=i,c=i;at)return wc(f,e,t,h);if(u>=e&&s==-1&&(s=a,r=h),h>t&&f.dom.parentNode==n.dom){o=a,l=c;break}c=u,h=u+f.breakAfter}return{from:r,to:l<0?i+n.length:l,startDOM:(s?n.children[s-1].dom.nextSibling:null)||n.dom.firstChild,endDOM:o=0?n.children[o].dom:null}}else return n.isText()?{from:i,to:i+n.length,startDOM:n.dom,endDOM:n.dom.nextSibling}:null}function vc(n,e){let t,{newSel:i}=e,{state:s}=n,r=s.selection.main,o=n.inputState.lastKeyTime>Date.now()-100?n.inputState.lastKeyCode:-1;if(e.bounds){let{from:l,to:a}=e.bounds,h=r.from,c=null;(o===8||D.android&&e.text.length=l&&r.to<=a&&(e.typeOver||f!=e.text)&&f.slice(0,r.from-l)==e.text.slice(0,r.from-l)&&f.slice(r.to-l)==e.text.slice(u=e.text.length-(f.length-(r.to-l)))?t={from:r.from,to:r.to,insert:F.of(e.text.slice(r.from-l,u).split(li))}:(d=kc(f,e.text,h-l,c))&&(D.chrome&&o==13&&d.toB==d.from+2&&e.text.slice(d.from,d.toB)==li+li&&d.toB--,t={from:l+d.from,to:l+d.toA,insert:F.of(e.text.slice(d.from,d.toB).split(li))})}else i&&(!n.hasFocus&&s.facet(at)||hs(i,r))&&(i=null);if(!t&&!i)return!1;if((D.mac||D.android)&&t&&t.from==t.to&&t.from==r.head-1&&/^\. ?$/.test(t.insert.toString())&&n.contentDOM.getAttribute("autocorrect")=="off"?(i&&t.insert.length==2&&(i=b.single(i.main.anchor-1,i.main.head-1)),t={from:t.from,to:t.to,insert:F.of([t.insert.toString().replace("."," ")])}):s.doc.lineAt(r.from).toDate.now()-50?t={from:r.from,to:r.to,insert:s.toText(n.inputState.insertingText)}:D.chrome&&t&&t.from==t.to&&t.from==r.head&&t.insert.toString()==` + `&&n.lineWrapping&&(i&&(i=b.single(i.main.anchor-1,i.main.head-1)),t={from:r.from,to:r.to,insert:F.of([" "])}),t)return Jo(n,t,i,o);if(i&&!hs(i,r)){let l=!1,a="select";return n.inputState.lastSelectionTime>Date.now()-50&&(n.inputState.lastSelectionOrigin=="select"&&(l=!0),a=n.inputState.lastSelectionOrigin,a=="select.pointer"&&(i=xc(s.facet(Qi).map(h=>h(n)),i))),n.dispatch({selection:i,scrollIntoView:l,userEvent:a}),!0}else return!1}function Jo(n,e,t,i=-1){if(D.ios&&n.inputState.flushIOSKey(e))return!0;let s=n.state.selection.main;if(D.android&&(e.to==s.to&&(e.from==s.from||e.from==s.from-1&&n.state.sliceDoc(e.from,s.from)==" ")&&e.insert.length==1&&e.insert.lines==2&&di(n.contentDOM,"Enter",13)||(e.from==s.from-1&&e.to==s.to&&e.insert.length==0||i==8&&e.insert.lengths.head)&&di(n.contentDOM,"Backspace",8)||e.from==s.from&&e.to==s.to+1&&e.insert.length==0&&di(n.contentDOM,"Delete",46)))return!0;let r=e.insert.toString();n.inputState.composing>=0&&n.inputState.composing++;let o,l=()=>o||(o=Wp(n,e,t));return n.state.facet(lc).some(a=>a(n,e.from,e.to,r,l))||n.dispatch(l()),!0}function Wp(n,e,t){let i,s=n.state,r=s.selection.main,o=-1;if(e.from==e.to&&e.fromr.to){let a=e.fromf(n)),h,a);e.from==c&&(o=c)}if(o>-1)i={changes:e,selection:b.cursor(e.from+e.insert.length,-1)};else if(e.from>=r.from&&e.to<=r.to&&e.to-e.from>=(r.to-r.from)/3&&(!t||t.main.empty&&t.main.from==e.from+e.insert.length)&&n.inputState.composing<0){let a=r.frome.to?s.sliceDoc(e.to,r.to):"";i=s.replaceSelection(n.state.toText(a+e.insert.sliceString(0,void 0,n.state.lineBreak)+h))}else{let a=s.changes(e),h=t&&t.main.to<=a.newLength?t.main:void 0;if(s.selection.ranges.length>1&&(n.inputState.composing>=0||n.inputState.compositionPendingChange)&&e.to<=r.to+10&&e.to>=r.to-10){let c=n.state.sliceDoc(e.from,e.to),f,u=t&&bc(n,t.main.head);if(u){let p=e.insert.length-(e.to-e.from);f={from:u.from,to:u.to-p}}else f=n.state.doc.lineAt(r.head);let d=r.to-e.to;i=s.changeByRange(p=>{if(p.from==r.from&&p.to==r.to)return{changes:a,range:h||p.map(a)};let m=p.to-d,g=m-c.length;if(n.state.sliceDoc(g,m)!=c||m>=f.from&&g<=f.to)return{range:p};let y=s.changes({from:g,to:m,insert:e.insert}),x=p.to-r.to;return{changes:y,range:h?b.range(Math.max(0,h.anchor+x),Math.max(0,h.head+x)):p.map(y)}})}else i={changes:a,selection:h&&s.selection.replaceRange(h)}}let l="input.type";return(n.composing||n.inputState.compositionPendingChange&&n.inputState.compositionEndedAt>Date.now()-50)&&(n.inputState.compositionPendingChange=!1,l+=".compose",n.inputState.compositionFirstChange&&(l+=".start",n.inputState.compositionFirstChange=!1)),s.update(i,{userEvent:l,scrollIntoView:!0})}function kc(n,e,t,i){let s=Math.min(n.length,e.length),r=0;for(;r0&&l>0&&n.charCodeAt(o-1)==e.charCodeAt(l-1);)o--,l--;if(i=="end"){let a=Math.max(0,r-Math.min(o,l));t-=o+a-r}if(o=o?r-t:0;r-=a,l=r+(l-o),o=r}else if(l=l?r-t:0;r-=a,o=r+(o-l),l=r}return{from:r,toA:o,toB:l}}function Vp(n){let e=[];if(n.root.activeElement!=n.contentDOM)return e;let{anchorNode:t,anchorOffset:i,focusNode:s,focusOffset:r}=n.observer.selectionRange;return t&&(e.push(new as(t,i)),(s!=t||r!=i)&&e.push(new as(s,r))),e}function zp(n,e){if(n.length==0)return null;let t=n[0].pos,i=n.length==2?n[1].pos:t;return t>-1&&i>-1?b.single(t+e,i+e):null}function hs(n,e){return e.head==n.main.head&&e.anchor==n.main.anchor}var go=class{setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}constructor(e){this.view=e,this.lastKeyCode=0,this.lastKeyTime=0,this.touchActive=!1,this.lastTouchTime=0,this.lastTouchX=0,this.lastTouchY=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.lastWheelEvent=0,this.pendingIOSKey=void 0,this.lastIOSMomentumScroll=0,this.tabFocusMode=-1,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastContextMenu=0,this.scrollHandlers=[],this.handlers=Object.create(null),this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.compositionPendingKey=!1,this.compositionPendingChange=!1,this.insertingText="",this.insertingTextAt=0,this.mouseSelection=null,this.draggedContent=null,this.handleEvent=this.handleEvent.bind(this),this.notifiedFocused=e.hasFocus,D.safari&&e.contentDOM.addEventListener("input",()=>null),D.gecko&&im(e.contentDOM.ownerDocument)}handleEvent(e){!Yp(this.view,e)||this.ignoreDuringComposition(e)||e.type=="keydown"&&this.keydown(e)||(this.view.updateState!=0?Promise.resolve().then(()=>this.runHandlers(e.type,e)):this.runHandlers(e.type,e))}runHandlers(e,t){let i=this.handlers[e];if(i){for(let s of i.observers)s(this.view,t);for(let s of i.handlers){if(t.defaultPrevented)break;if(s(this.view,t)){t.preventDefault();break}}}}ensureHandlers(e){let t=Kp(e),i=this.handlers,s=this.view.contentDOM;for(let r in t)if(r!="scroll"){let o=!t[r].handlers.length,l=i[r];l&&o!=!l.handlers.length&&(s.removeEventListener(r,this.handleEvent),l=null),l||s.addEventListener(r,this.handleEvent,{passive:o})}for(let r in i)r!="scroll"&&!t[r]&&s.removeEventListener(r,this.handleEvent);this.handlers=t}keydown(e){if(this.lastKeyCode=e.keyCode,this.lastKeyTime=Date.now(),e.keyCode==9&&this.tabFocusMode>-1&&(!this.tabFocusMode||Date.now()<=this.tabFocusMode))return!0;if(this.tabFocusMode>0&&e.keyCode!=27&&Cc.indexOf(e.keyCode)<0&&(this.tabFocusMode=-1),D.android&&D.chrome&&!e.synthetic&&(e.keyCode==13||e.keyCode==8))return this.view.observer.delayAndroidKey(e.key,e.keyCode),!0;if(D.ios&&!e.synthetic&&!e.altKey&&!e.metaKey&&(Sc.some(t=>t.keyCode==e.keyCode)&&!e.ctrlKey||$p.indexOf(e.key)>-1&&e.ctrlKey)){let t={ctrlKey:e.ctrlKey,altKey:e.altKey,metaKey:e.metaKey,shiftKey:e.shiftKey};return t.shiftKey&&D.ios&&!/^(off|none)$/.test(this.view.contentDOM.autocapitalize)&&qp(this.view.win)&&(t.shiftKey=!1),this.pendingIOSKey={key:e.key,keyCode:e.keyCode,mods:t},setTimeout(()=>this.flushIOSKey(),250),!0}return e.keyCode!=229&&this.view.observer.forceFlush(),!1}flushIOSKey(e){let t=this.pendingIOSKey;return!t||t.key=="Enter"&&e&&e.from0?!0:D.safari&&!D.ios&&this.compositionPendingKey&&Date.now()-this.compositionEndedAt<100?(this.compositionPendingKey=!1,!0):!1}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.view.observer.update(e),this.mouseSelection&&this.mouseSelection.update(e),this.draggedContent&&e.docChanged&&(this.draggedContent=this.draggedContent.map(e.changes)),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}};function qp(n){return n.visualViewport?n.visualViewport.height*n.visualViewport.scale/n.document.documentElement.clientHeight<.85:!1}function gh(n,e){return(t,i)=>{try{return e.call(n,i,t)}catch(s){re(t.state,s)}}}function Kp(n){let e=Object.create(null);function t(i){return e[i]||(e[i]={observers:[],handlers:[]})}for(let i of n){let s=i.spec,r=s&&s.plugin.domEventHandlers,o=s&&s.plugin.domEventObservers;if(r)for(let l in r){let a=r[l];a&&t(l).handlers.push(gh(i.value,a))}if(o)for(let l in o){let a=o[l];a&&t(l).observers.push(gh(i.value,a))}}for(let i in $e)t(i).handlers.push($e[i]);for(let i in we)t(i).observers.push(we[i]);return e}var Sc=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Enter",keyCode:13,inputType:"insertLineBreak"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],$p="dthko",Cc=[16,17,18,20,91,92,224,225],qn=6;function Kn(n){return Math.max(0,n)*.7+8}function jp(n,e){return Math.max(Math.abs(n.clientX-e.clientX),Math.abs(n.clientY-e.clientY))}var yo=class{constructor(e,t,i,s){this.view=e,this.startEvent=t,this.style=i,this.mustSelect=s,this.scrollSpeed={x:0,y:0},this.scrolling=-1,this.lastEvent=t,this.scrollParents=Gh(e.contentDOM),this.atoms=e.state.facet(Qi).map(o=>o(e));let r=e.contentDOM.ownerDocument;r.addEventListener("mousemove",this.move=this.move.bind(this)),r.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(j.allowMultipleSelections)&&Up(e,t),this.dragging=_p(e,t)&&Tc(t)==1?null:!1}start(e){this.dragging===!1&&this.select(e)}move(e){if(e.buttons==0)return this.destroy();if(this.dragging||this.dragging==null&&jp(this.startEvent,e)<10)return;this.select(this.lastEvent=e);let t=0,i=0,s=0,r=0,o=this.view.win.innerWidth,l=this.view.win.innerHeight;this.scrollParents.x&&({left:s,right:o}=this.scrollParents.x.getBoundingClientRect()),this.scrollParents.y&&({top:r,bottom:l}=this.scrollParents.y.getBoundingClientRect());let a=Yo(this.view);e.clientX-a.left<=s+qn?t=-Kn(s-e.clientX):e.clientX+a.right>=o-qn&&(t=Kn(e.clientX-o)),e.clientY-a.top<=r+qn?i=-Kn(r-e.clientY):e.clientY+a.bottom>=l-qn&&(i=Kn(e.clientY-l)),this.setScrollSpeed(t,i)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){this.setScrollSpeed(0,0);let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.inputState.mouseSelection=this.view.inputState.draggedContent=null}setScrollSpeed(e,t){this.scrollSpeed={x:e,y:t},e||t?this.scrolling<0&&(this.scrolling=setInterval(()=>this.scroll(),50)):this.scrolling>-1&&(clearInterval(this.scrolling),this.scrolling=-1)}scroll(){let{x:e,y:t}=this.scrollSpeed;e&&this.scrollParents.x&&(this.scrollParents.x.scrollLeft+=e,e=0),t&&this.scrollParents.y&&(this.scrollParents.y.scrollTop+=t,t=0),(e||t)&&this.view.win.scrollBy(e,t),this.dragging===!1&&this.select(this.lastEvent)}select(e){let{view:t}=this,i=xc(this.atoms,this.style.get(e,this.extend,this.multiple));(this.mustSelect||!i.eq(t.state.selection,this.dragging===!1))&&this.view.dispatch({selection:i,userEvent:"select.pointer"}),this.mustSelect=!1}update(e){e.transactions.some(t=>t.isUserEvent("input.type"))?this.destroy():this.style.update(e)&&setTimeout(()=>this.select(this.lastEvent),20)}};function Up(n,e){let t=n.state.facet(nc);return t.length?t[0](e):D.mac?e.metaKey:e.ctrlKey}function Gp(n,e){let t=n.state.facet(sc);return t.length?t[0](e):D.mac?!e.altKey:!e.ctrlKey}function _p(n,e){let{main:t}=n.state.selection;if(t.empty)return!1;let i=Ji(n.root);if(!i||i.rangeCount==0)return!0;let s=i.getRangeAt(0).getClientRects();for(let r=0;r=e.clientX&&o.top<=e.clientY&&o.bottom>=e.clientY)return!0}return!1}function Yp(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target,i;t!=n.contentDOM;t=t.parentNode)if(!t||t.nodeType==11||(i=X.get(t))&&i.isWidget()&&!i.isHidden&&i.widget.ignoreEvent(e))return!1;return!0}var $e=Object.create(null),we=Object.create(null),Ac=D.ie&&D.ie_version<15||D.ios&&D.webkit_version<604;function Jp(n){let e=n.dom.parentNode;if(!e)return;let t=e.appendChild(document.createElement("textarea"));t.style.cssText="position: fixed; left: -10000px; top: 10px",t.focus(),setTimeout(()=>{n.focus(),t.remove(),Mc(n,t.value)},50)}function vs(n,e,t){for(let i of n.facet(e))t=i(t,n);return t}function Mc(n,e){e=vs(n.state,jo,e);let{state:t}=n,i,s=1,r=t.toText(e),o=r.lines==t.selection.ranges.length;if(bo!=null&&t.selection.ranges.every(a=>a.empty)&&bo==r.toString()){let a=-1;i=t.changeByRange(h=>{let c=t.doc.lineAt(h.from);if(c.from==a)return{range:h};a=c.from;let f=t.toText((o?r.line(s++).text:e)+t.lineBreak);return{changes:{from:c.from,insert:f},range:b.cursor(h.from+f.length)}})}else o?i=t.changeByRange(a=>{let h=r.line(s++);return{changes:{from:a.from,to:a.to,insert:h.text},range:b.cursor(a.from+h.length)}}):i=t.replaceSelection(r);n.dispatch(i,{userEvent:"input.paste",scrollIntoView:!0})}we.scroll=n=>{let e=n.inputState;e.lastScrollTop=n.scrollDOM.scrollTop,e.lastScrollLeft=n.scrollDOM.scrollLeft,D.ios&&!e.touchActive&&(e.lastIOSMomentumScroll=Date.now())};we.wheel=we.mousewheel=n=>{n.inputState.lastWheelEvent=Date.now()};$e.keydown=(n,e)=>(n.inputState.setSelectionOrigin("select"),e.keyCode==27&&n.inputState.tabFocusMode!=0&&(n.inputState.tabFocusMode=Date.now()+2e3),!1);we.touchstart=(n,e)=>{let t=n.inputState,i=e.targetTouches[0];t.touchActive=!0,t.lastTouchTime=Date.now(),i&&(t.lastTouchX=i.clientX,t.lastTouchY=i.clientY),t.setSelectionOrigin("select.pointer")};we.touchmove=n=>{n.inputState.setSelectionOrigin("select.pointer")};we.touchend=(n,e)=>{n.inputState.touchActive=!1};$e.mousedown=(n,e)=>{if(n.observer.flush(),n.inputState.lastTouchTime>Date.now()-2e3)return!1;let t=null;for(let i of n.state.facet(rc))if(t=i(n,e),t)break;if(!t&&e.button==0&&(t=Qp(n,e)),t){let i=!n.hasFocus;n.inputState.startMouseSelection(new yo(n,e,t,i)),i&&n.observer.ignore(()=>{_h(n.contentDOM);let r=n.root.activeElement;r&&!r.contains(n.contentDOM)&&r.blur()});let s=n.inputState.mouseSelection;if(s)return s.start(e),s.dragging===!1}else n.inputState.setSelectionOrigin("select.pointer");return!1};function yh(n,e,t,i){if(i==1)return b.cursor(e,t);if(i==2)return Ep(n.state,e,t);{let s=n.docView.lineAt(e,t),r=n.state.doc.lineAt(s?s.posAtEnd:e),o=s?s.posAtStart:r.from,l=s?s.posAtEnd:r.to;return lDate.now()-400&&Math.abs(e.clientX-n.clientX)<2&&Math.abs(e.clientY-n.clientY)<2?(xh+1)%3:1}function Qp(n,e){let t=n.posAndSideAtCoords({x:e.clientX,y:e.clientY},!1),i=Tc(e),s=n.state.selection;return{update(r){r.docChanged&&(t.pos=r.changes.mapPos(t.pos),s=s.map(r.changes))},get(r,o,l){let a=n.posAndSideAtCoords({x:r.clientX,y:r.clientY},!1),h,c=yh(n,a.pos,a.assoc,i);if(t.pos!=a.pos&&!o){let f=yh(n,t.pos,t.assoc,i),u=Math.min(f.from,c.from),d=Math.max(f.to,c.to);c=u1&&(h=Zp(s,a.pos))?h:l?s.addRange(c):b.create([c])}}}function Zp(n,e){for(let t=0;t=e)return b.create(n.ranges.slice(0,t).concat(n.ranges.slice(t+1)),n.mainIndex==t?0:n.mainIndex-(n.mainIndex>t?1:0))}return null}$e.dragstart=(n,e)=>{let{selection:{main:t}}=n.state;if(e.target.draggable){let s=n.docView.tile.nearest(e.target);if(s&&s.isWidget()){let r=s.posAtStart,o=r+s.length;(r>=t.to||o<=t.from)&&(t=b.undirectionalRange(r,o))}}let{inputState:i}=n;return i.mouseSelection&&(i.mouseSelection.dragging=!0),i.draggedContent=t,e.dataTransfer&&(e.dataTransfer.setData("Text",vs(n.state,Uo,n.state.sliceDoc(t.from,t.to))),e.dataTransfer.effectAllowed="copyMove"),!1};$e.dragend=n=>(n.inputState.draggedContent=null,!1);function vh(n,e,t,i){if(t=vs(n.state,jo,t),!t)return;let s=n.posAtCoords({x:e.clientX,y:e.clientY},!1),{draggedContent:r}=n.inputState,o=i&&r&&Gp(n,e)?{from:r.from,to:r.to}:null,l={from:s,insert:t},a=n.state.changes(o?[o,l]:l);n.focus(),n.dispatch({changes:a,selection:{anchor:a.mapPos(s,-1),head:a.mapPos(s,1)},userEvent:o?"move.drop":"input.drop"}),n.inputState.draggedContent=null}$e.drop=(n,e)=>{if(!e.dataTransfer)return!1;if(n.state.readOnly)return!0;let t=e.dataTransfer.files;if(t&&t.length){let i=Array(t.length),s=0,r=()=>{++s==t.length&&vh(n,e,i.filter(o=>o!=null).join(n.state.lineBreak),!1)};for(let o=0;o{/[\x00-\x08\x0e-\x1f]{2}/.test(l.result)||(i[o]=l.result),r()},l.readAsText(t[o])}return!0}else{let i=e.dataTransfer.getData("Text");if(i)return vh(n,e,i,!0),!0}return!1};$e.paste=(n,e)=>{if(n.state.readOnly)return!0;n.observer.flush();let t=Ac?null:e.clipboardData;return t?(Mc(n,t.getData("text/plain")||t.getData("text/uri-list")),!0):(Jp(n),!1)};function em(n,e){let t=n.dom.parentNode;if(!t)return;let i=t.appendChild(document.createElement("textarea"));i.style.cssText="position: fixed; left: -10000px; top: 10px",i.value=e,i.focus(),i.selectionEnd=e.length,i.selectionStart=0,setTimeout(()=>{i.remove(),n.focus()},50)}function tm(n){let e=[],t=[],i=!1;for(let s of n.selection.ranges)s.empty||(e.push(n.sliceDoc(s.from,s.to)),t.push(s));if(!e.length){let s=-1;for(let{from:r}of n.selection.ranges){let o=n.doc.lineAt(r);o.number>s&&(e.push(o.text),t.push({from:o.from,to:Math.min(n.doc.length,o.to+1)})),s=o.number}i=!0}return{text:vs(n,Uo,e.join(n.lineBreak)),ranges:t,linewise:i}}var bo=null;$e.copy=$e.cut=(n,e)=>{if(!Wi(n.contentDOM,n.observer.selectionRange))return!1;let{text:t,ranges:i,linewise:s}=tm(n.state);if(!t&&!s)return!1;bo=s?t:null,e.type=="cut"&&!n.state.readOnly&&n.dispatch({changes:i,scrollIntoView:!0,userEvent:"delete.cut"});let r=Ac?null:e.clipboardData;return r?(r.clearData(),r.setData("text/plain",t),!0):(em(n,t),!1)};var Dc=ke.define();function Oc(n,e){let t=[];for(let i of n.facet(ac)){let s=i(n,e);s&&t.push(s)}return t.length?n.update({effects:t,annotations:Dc.of(!0)}):null}function Bc(n){setTimeout(()=>{let e=n.hasFocus;if(e!=n.inputState.notifiedFocused){let t=Oc(n.state,e);t?n.dispatch(t):n.update([])}},10)}we.focus=n=>{n.inputState.lastFocusTime=Date.now(),!n.scrollDOM.scrollTop&&(n.inputState.lastScrollTop||n.inputState.lastScrollLeft)&&(n.scrollDOM.scrollTop=n.inputState.lastScrollTop,n.scrollDOM.scrollLeft=n.inputState.lastScrollLeft),Bc(n)};we.blur=n=>{n.observer.clearSelectionRange(),Bc(n)};we.compositionstart=we.compositionupdate=n=>{n.observer.editContext||(n.inputState.compositionFirstChange==null&&(n.inputState.compositionFirstChange=!0),n.inputState.composing<0&&(n.inputState.composing=0))};we.compositionend=n=>{n.observer.editContext||(n.inputState.composing=-1,n.inputState.compositionEndedAt=Date.now(),n.inputState.compositionPendingKey=!0,n.inputState.compositionPendingChange=n.observer.pendingRecords().length>0,n.inputState.compositionFirstChange=null,D.chrome&&D.android?n.observer.flushSoon():n.inputState.compositionPendingChange?Promise.resolve().then(()=>n.observer.flush()):setTimeout(()=>{n.inputState.composing<0&&n.docView.hasComposition&&n.update([])},50))};we.contextmenu=n=>{n.inputState.lastContextMenu=Date.now()};$e.beforeinput=(n,e)=>{var t,i;if((e.inputType=="insertText"||e.inputType=="insertCompositionText")&&(n.inputState.insertingText=e.data,n.inputState.insertingTextAt=Date.now()),e.inputType=="insertReplacementText"&&n.observer.editContext){let r=(t=e.dataTransfer)===null||t===void 0?void 0:t.getData("text/plain"),o=e.getTargetRanges();if(r&&o.length){let l=o[0],a=n.posAtDOM(l.startContainer,l.startOffset),h=n.posAtDOM(l.endContainer,l.endOffset);return Jo(n,{from:a,to:h,insert:n.state.toText(r)},null),!0}}let s;if(D.chrome&&D.android&&(s=Sc.find(r=>r.inputType==e.inputType))&&(n.observer.delayAndroidKey(s.key,s.keyCode),s.key=="Backspace"||s.key=="Delete")){let r=((i=window.visualViewport)===null||i===void 0?void 0:i.height)||0;setTimeout(()=>{var o;(((o=window.visualViewport)===null||o===void 0?void 0:o.height)||0)>r+10&&n.hasFocus&&(n.contentDOM.blur(),n.focus())},100)}return D.ios&&e.inputType=="deleteContentForward"&&n.observer.flushSoon(),D.safari&&e.inputType=="insertText"&&n.inputState.composing>=0&&setTimeout(()=>we.compositionend(n,e),20),!1};var kh=new Set;function im(n){kh.has(n)||(kh.add(n),n.addEventListener("copy",()=>{}),n.addEventListener("cut",()=>{}))}var Sh=["pre-wrap","normal","pre-line","break-spaces"],bi=!1;function Ch(){bi=!1}var xo=class{constructor(e){this.lineWrapping=e,this.doc=F.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.textHeight=14,this.lineLength=30}heightForGap(e,t){let i=this.doc.lineAt(t).number-this.doc.lineAt(e).number+1;return this.lineWrapping&&(i+=Math.max(0,Math.ceil((t-e-i*this.lineLength*.5)/this.lineLength))),this.lineHeight*i}heightForLine(e){return this.lineWrapping?(1+Math.max(0,Math.ceil((e-this.lineLength)/Math.max(1,this.lineLength-5))))*this.lineHeight:this.lineHeight}setDoc(e){return this.doc=e,this}mustRefreshForWrapping(e){return Sh.indexOf(e)>-1!=this.lineWrapping}mustRefreshForHeights(e){let t=!1;for(let i=0;i-1,a=Math.abs(t-this.lineHeight)>.3||this.lineWrapping!=l;if(this.lineWrapping=l,this.lineHeight=t,this.charWidth=i,this.textHeight=s,this.lineLength=r,a){this.heightSamples={};for(let h=0;h0}set outdated(e){this.flags=(e?2:0)|this.flags&-3}setHeight(e){this.height!=e&&(Math.abs(this.height-e)>Xn&&(bi=!0),this.height=e)}replace(e,t,i){return n.of(i)}decomposeLeft(e,t){t.push(this)}decomposeRight(e,t){t.push(this)}applyChanges(e,t,i,s){let r=this,o=i.doc;for(let l=s.length-1;l>=0;l--){let{fromA:a,toA:h,fromB:c,toB:f}=s[l],u=r.lineAt(a,_.ByPosNoHeight,i.setDoc(t),0,0),d=u.to>=h?u:r.lineAt(h,_.ByPosNoHeight,i,0,0);for(f+=d.to-h,h=d.to;l>0&&u.from<=s[l-1].toA;)a=s[l-1].fromA,c=s[l-1].fromB,l--,ar*2){let l=e[t-1];l.break?e.splice(--t,1,l.left,null,l.right):e.splice(--t,1,l.left,l.right),i+=1+l.break,s-=l.size}else if(r>s*2){let l=e[i];l.break?e.splice(i,1,l.left,null,l.right):e.splice(i,1,l.left,l.right),i+=2+l.break,r-=l.size}else break;else if(s=r&&o(this.lineAt(0,_.ByPos,i,s,r))}setMeasuredHeight(e){let t=e.heights[e.index++];t<0?(this.spaceAbove=-t,t=e.heights[e.index++]):this.spaceAbove=0,this.setHeight(t)}updateHeight(e,t=0,i=!1,s){return s&&s.from<=t&&s.more&&this.setMeasuredHeight(s),this.outdated=!1,this}toString(){return`block(${this.length})`}},Ne=class n extends fs{constructor(e,t,i){super(e,t,null),this.collapsed=0,this.widgetHeight=0,this.breaks=0,this.spaceAbove=i}mainBlock(e,t){return new qe(t,this.length,e+this.spaceAbove,this.height-this.spaceAbove,this.breaks)}replace(e,t,i){let s=i[0];return i.length==1&&(s instanceof n||s instanceof bt&&s.flags&4)&&Math.abs(this.length-s.length)<10?(s instanceof bt?s=new n(s.length,this.height,this.spaceAbove):s.height=this.height,this.outdated||(s.outdated=!1),s):Be.of(i)}updateHeight(e,t=0,i=!1,s){return s&&s.from<=t&&s.more?this.setMeasuredHeight(s):(i||this.outdated)&&(this.spaceAbove=0,this.setHeight(Math.max(this.widgetHeight,e.heightForLine(this.length-this.collapsed))+this.breaks*e.lineHeight)),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:""}${this.widgetHeight?":"+this.widgetHeight:""})`}},bt=class n extends Be{constructor(e){super(e,0)}heightMetrics(e,t){let i=e.doc.lineAt(t).number,s=e.doc.lineAt(t+this.length).number,r=s-i+1,o,l=0;if(e.lineWrapping){let a=Math.min(this.height,e.lineHeight*r);o=a/r,this.length>r+1&&(l=(this.height-a)/(this.length-r-1))}else o=this.height/r;return{firstLine:i,lastLine:s,perLine:o,perChar:l}}blockAt(e,t,i,s){let{firstLine:r,lastLine:o,perLine:l,perChar:a}=this.heightMetrics(t,s);if(t.lineWrapping){let h=s+(e0){let r=i[i.length-1];r instanceof n?i[i.length-1]=new n(r.length+s):i.push(null,new n(s-1))}if(e>0){let r=i[0];r instanceof n?i[0]=new n(e+r.length):i.unshift(new n(e-1),null)}return Be.of(i)}decomposeLeft(e,t){t.push(new n(e-1),null)}decomposeRight(e,t){t.push(null,new n(this.length-e-1))}updateHeight(e,t=0,i=!1,s){let r=t+this.length;if(s&&s.from<=t+this.length&&s.more){let o=[],l=Math.max(t,s.from),a=-1;for(s.from>t&&o.push(new n(s.from-t-1).updateHeight(e,t));l<=r&&s.more;){let c=e.doc.lineAt(l).length;o.length&&o.push(null);let f=s.heights[s.index++],u=0;f<0&&(u=-f,f=s.heights[s.index++]),a==-1?a=f:Math.abs(f-a)>=Xn&&(a=-2);let d=new Ne(c,f,u);d.outdated=!1,o.push(d),l+=c+1}l<=r&&o.push(null,new n(r-l).updateHeight(e,l));let h=Be.of(o);return(a<0||Math.abs(h.height-this.height)>=Xn||Math.abs(a-this.heightMetrics(e,t).perLine)>=Xn)&&(bi=!0),cs(this,h)}else(i||this.outdated)&&(this.setHeight(e.heightForGap(t,t+this.length)),this.outdated=!1);return this}toString(){return`gap(${this.length})`}},vo=class extends Be{constructor(e,t,i){super(e.length+t+i.length,e.height+i.height,t|(e.outdated||i.outdated?2:0)),this.left=e,this.right=i,this.size=e.size+i.size}get break(){return this.flags&1}blockAt(e,t,i,s){let r=i+this.left.height;return el))return h;let c=t==_.ByPosNoHeight?_.ByPosNoHeight:_.ByPos;return a?h.join(this.right.lineAt(l,c,i,o,l)):this.left.lineAt(l,c,i,s,r).join(h)}forEachLine(e,t,i,s,r,o){let l=s+this.left.height,a=r+this.left.length+this.break;if(this.break)e=a&&this.right.forEachLine(e,t,i,l,a,o);else{let h=this.lineAt(a,_.ByPos,i,s,r);e=e&&h.from<=t&&o(h),t>h.to&&this.right.forEachLine(h.to+1,t,i,l,a,o)}}replace(e,t,i){let s=this.left.length+this.break;if(tthis.left.length)return this.balanced(this.left,this.right.replace(e-s,t-s,i));let r=[];e>0&&this.decomposeLeft(e,r);let o=r.length;for(let l of i)r.push(l);if(e>0&&Ah(r,o-1),t=i&&t.push(null)),e>i&&this.right.decomposeLeft(e-i,t)}decomposeRight(e,t){let i=this.left.length,s=i+this.break;if(e>=s)return this.right.decomposeRight(e-s,t);e2*t.size||t.size>2*e.size?Be.of(this.break?[e,null,t]:[e,t]):(this.left=cs(this.left,e),this.right=cs(this.right,t),this.setHeight(e.height+t.height),this.outdated=e.outdated||t.outdated,this.size=e.size+t.size,this.length=e.length+this.break+t.length,this)}updateHeight(e,t=0,i=!1,s){let{left:r,right:o}=this,l=t+r.length+this.break,a=null;return s&&s.from<=t+r.length&&s.more?a=r=r.updateHeight(e,t,i,s):r.updateHeight(e,t,i),s&&s.from<=l+o.length&&s.more?a=o=o.updateHeight(e,l,i,s):o.updateHeight(e,l,i),a?this.balanced(r,o):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?" ":"-")+this.right}};function Ah(n,e){let t,i;n[e]==null&&(t=n[e-1])instanceof bt&&(i=n[e+1])instanceof bt&&n.splice(e-1,3,new bt(t.length+1+i.length))}var sm=5,ko=class n{constructor(e,t){this.pos=e,this.oracle=t,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=e}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(e,t){if(this.lineStart>-1){let i=Math.min(t,this.lineEnd),s=this.nodes[this.nodes.length-1];s instanceof Ne?s.length+=i-this.pos:(i>this.pos||!this.isCovered)&&this.nodes.push(new Ne(i-this.pos,-1,0)),this.writtenTo=i,t>i&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=t}point(e,t,i){if(e=sm)&&this.addLineDeco(s,r,o)}else t>e&&this.span(e,t);this.lineEnd>-1&&this.lineEnd-1)return;let{from:e,to:t}=this.oracle.doc.lineAt(this.pos);this.lineStart=e,this.lineEnd=t,this.writtenToe&&this.nodes.push(new Ne(this.pos-e,-1,0)),this.writtenTo=this.pos}blankContent(e,t){let i=new bt(t-e);return this.oracle.doc.lineAt(e).to==t&&(i.flags|=4),i}ensureLine(){this.enterLine();let e=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(e instanceof Ne)return e;let t=new Ne(0,-1,0);return this.nodes.push(t),t}addBlock(e){this.enterLine();let t=e.deco;t&&t.startSide>0&&!this.isCovered&&this.ensureLine(),this.nodes.push(e),this.writtenTo=this.pos=this.pos+e.length,t&&t.endSide>0&&(this.covering=e)}addLineDeco(e,t,i){let s=this.ensureLine();s.length+=i,s.collapsed+=i,s.widgetHeight=Math.max(s.widgetHeight,e),s.breaks+=t,this.writtenTo=this.pos=this.pos+i}finish(e){let t=this.nodes.length==0?null:this.nodes[this.nodes.length-1];this.lineStart>-1&&!(t instanceof Ne)&&!this.isCovered?this.nodes.push(new Ne(0,-1,0)):(this.writtenToc.clientHeight||c.scrollWidth>c.clientWidth)&&f.overflow!="visible"){let u=c.getBoundingClientRect();r=Math.max(r,u.left),o=Math.min(o,u.right),l=Math.max(l,u.top),a=Math.min(h==n.parentNode?s.innerHeight:a,u.bottom)}h=f.position=="absolute"||f.position=="fixed"?c.offsetParent:c.parentNode}else if(h.nodeType==11)h=h.host;else break;return{left:r-t.left,right:Math.max(r,o)-t.left,top:l-(t.top+e),bottom:Math.max(l,a)-(t.top+e)}}function lm(n){let e=n.getBoundingClientRect(),t=n.ownerDocument.defaultView||window;return e.left0&&e.top0}function am(n,e){let t=n.getBoundingClientRect();return{left:0,right:t.right-t.left,top:e,bottom:t.bottom-(t.top+e)}}var ji=class{constructor(e,t,i,s){this.from=e,this.to=t,this.size=i,this.displaySize=s}static same(e,t){if(e.length!=t.length)return!1;for(let i=0;itypeof s!="function"&&s.class=="cm-lineWrapping");this.heightOracle=new xo(i),this.stateDeco=Th(t),this.heightMap=Be.empty().applyChanges(this.stateDeco,F.empty,this.heightOracle.setDoc(t.doc),[new Ke(0,0,0,t.doc.length)]);for(let s=0;s<2&&(this.viewport=this.getViewport(0,null),!!this.updateForViewport());s++);this.updateViewportLines(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=B.set(this.lineGaps.map(s=>s.draw(this,!1))),this.scrollParent=e.scrollDOM,this.computeVisibleRanges()}updateForViewport(){let e=[this.viewport],{main:t}=this.state.selection;for(let i=0;i<=1;i++){let s=i?t.head:t.anchor;if(!e.some(({from:r,to:o})=>s>=r&&s<=o)){let{from:r,to:o}=this.lineBlockAt(s);e.push(new hi(r,o))}}return this.viewports=e.sort((i,s)=>i.from-s.from),this.updateScaler()}updateScaler(){let e=this.scaler;return this.scaler=this.heightMap.height<=7e6?Mh:new Ao(this.heightOracle,this.heightMap,this.viewports),e.eq(this.scaler)?0:2}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.heightOracle.setDoc(this.state.doc),0,0,e=>{this.viewportLines.push(Fi(e,this.scaler))})}update(e,t=null){this.state=e.state;let i=this.stateDeco;this.stateDeco=Th(this.state);let s=e.changedRanges,r=Ke.extendWithRanges(s,rm(i,this.stateDeco,e?e.changes:ye.empty(this.state.doc.length))),o=this.heightMap.height,l=this.scrolledToBottom?null:this.scrollAnchorAt(this.scrollOffset);Ch(),this.heightMap=this.heightMap.applyChanges(this.stateDeco,e.startState.doc,this.heightOracle.setDoc(this.state.doc),r),(this.heightMap.height!=o||bi)&&(e.flags|=2),l?(this.scrollAnchorPos=e.changes.mapPos(l.from,-1),this.scrollAnchorHeight=l.top):(this.scrollAnchorPos=-1,this.scrollAnchorHeight=o);let a=r.length?this.mapViewport(this.viewport,e.changes):this.viewport;(t&&(t.range.heada.to)||!this.viewportIsAppropriate(a))&&(a=this.getViewport(0,t));let h=a.from!=this.viewport.from||a.to!=this.viewport.to;this.viewport=a,e.flags|=this.updateForViewport(),(h||!e.changes.empty||e.flags&2)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,e.changes))),e.flags|=this.computeVisibleRanges(e.changes),t&&(this.scrollTarget=t),!this.mustEnforceCursorAssoc&&(e.selectionSet||e.focusChanged)&&e.view.lineWrapping&&e.state.selection.main.empty&&e.state.selection.main.assoc&&!e.state.facet(cc)&&(this.mustEnforceCursorAssoc=!0)}measure(){let{view:e}=this,t=e.contentDOM,i=window.getComputedStyle(t),s=this.heightOracle,r=i.whiteSpace;this.defaultTextDirection=i.direction=="rtl"?V.RTL:V.LTR;let o=this.heightOracle.mustRefreshForWrapping(r)||this.mustMeasureContent==="refresh",l=t.getBoundingClientRect(),a=o||this.mustMeasureContent||this.contentDOMHeight!=l.height;this.contentDOMHeight=l.height,this.mustMeasureContent=!1;let h=0,c=0;if(l.width&&l.height){let{scaleX:v,scaleY:k}=Uh(t,l);(v>.005&&Math.abs(this.scaleX-v)>.005||k>.005&&Math.abs(this.scaleY-k)>.005)&&(this.scaleX=v,this.scaleY=k,h|=16,o=a=!0)}let f=(parseInt(i.paddingTop)||0)*this.scaleY,u=(parseInt(i.paddingBottom)||0)*this.scaleY;(this.paddingTop!=f||this.paddingBottom!=u)&&(this.paddingTop=f,this.paddingBottom=u,h|=18),this.editorWidth!=e.scrollDOM.clientWidth&&(s.lineWrapping&&(a=!0),this.editorWidth=e.scrollDOM.clientWidth,h|=16);let d=Gh(this.view.contentDOM,!1).y;d!=this.scrollParent&&(this.scrollParent=d,this.scrollAnchorHeight=-1,this.scrollOffset=0);let p=this.getScrollOffset();this.scrollOffset!=p&&(this.scrollAnchorHeight=-1,this.scrollOffset=p),this.scrolledToBottom=Yh(this.scrollParent||e.win);let m=(this.printing?am:om)(t,this.paddingTop),g=m.top-this.pixelViewport.top,y=m.bottom-this.pixelViewport.bottom;this.pixelViewport=m;let x=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(x!=this.inView&&(this.inView=x,x&&(a=!0)),!this.inView&&!this.scrollTarget&&!lm(e.dom))return 0;let w=l.width;if((this.contentDOMWidth!=w||this.editorHeight!=e.scrollDOM.clientHeight)&&(this.contentDOMWidth=l.width,this.editorHeight=e.scrollDOM.clientHeight,h|=16),a){let v=e.docView.measureVisibleLineHeights(this.viewport);if(s.mustRefreshForHeights(v)&&(o=!0),o||s.lineWrapping&&Math.abs(w-this.contentDOMWidth)>s.charWidth){let{lineHeight:k,charWidth:S,textHeight:R}=e.docView.measureTextSize();o=k>0&&s.refresh(r,k,S,R,Math.max(5,w/S),v),o&&(e.docView.minWidth=0,h|=16)}g>0&&y>0?c=Math.max(g,y):g<0&&y<0&&(c=Math.min(g,y)),Ch();for(let k of this.viewports){let S=k.from==this.viewport.from?v:e.docView.measureVisibleLineHeights(k);this.heightMap=(o?Be.empty().applyChanges(this.stateDeco,F.empty,this.heightOracle,[new Ke(0,0,0,e.state.doc.length)]):this.heightMap).updateHeight(s,0,o,new wo(k.from,S))}bi&&(h|=2)}let O=!this.viewportIsAppropriate(this.viewport,c)||this.scrollTarget&&(this.scrollTarget.range.headthis.viewport.to);return O&&(h&2&&(h|=this.updateScaler()),this.viewport=this.getViewport(c,this.scrollTarget),h|=this.updateForViewport()),(h&2||O)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,e)),h|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,e.docView.enforceCursorAssoc()),h}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(e,t){let i=.5-Math.max(-.5,Math.min(.5,e/1e3/2)),s=this.heightMap,r=this.heightOracle,{visibleTop:o,visibleBottom:l}=this,a=new hi(s.lineAt(o-i*1e3,_.ByHeight,r,0,0).from,s.lineAt(l+(1-i)*1e3,_.ByHeight,r,0,0).to);if(t){let{head:h}=t.range;if(ha.to){let c=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),f=s.lineAt(h,_.ByPos,r,0,0),u;t.y=="center"?u=(f.top+f.bottom)/2-c/2:t.y=="start"||t.y=="nearest"&&h=l+Math.max(10,Math.min(i,250)))&&s>o-2*1e3&&r>1,o=s<<1;if(this.defaultTextDirection!=V.LTR&&!i)return[];let l=[],a=(c,f,u,d)=>{if(f-cc&&yy.from>=u.from&&y.to<=u.to&&Math.abs(y.from-c)y.fromx));if(!g){if(fw.from<=f&&w.to>=f)){let w=t.moveToLineBoundary(b.cursor(f),!1,!0).head;w>c&&(f=w)}let y=this.gapSize(u,c,f,d),x=i||y<2e6?y:2e6;g=new ji(c,f,y,x)}l.push(g)},h=c=>{if(c.length2e6)for(let k of e)k.from>=c.from&&k.fromc.from&&a(c.from,d,c,f),pt.draw(this,this.heightOracle.lineWrapping))))}computeVisibleRanges(e){let t=this.stateDeco;this.lineGaps.length&&(t=t.concat(this.lineGapDeco));let i=[];I.spans(t,this.viewport.from,this.viewport.to,{span(r,o){i.push({from:r,to:o})},point(){}},20);let s=0;if(i.length!=this.visibleRanges.length)s=12;else for(let r=0;r=this.viewport.from&&e<=this.viewport.to&&this.viewportLines.find(t=>t.from<=e&&t.to>=e)||Fi(this.heightMap.lineAt(e,_.ByPos,this.heightOracle,0,0),this.scaler)}lineBlockAtHeight(e){return e>=this.viewportLines[0].top&&e<=this.viewportLines[this.viewportLines.length-1].bottom&&this.viewportLines.find(t=>t.top<=e&&t.bottom>=e)||Fi(this.heightMap.lineAt(this.scaler.fromDOM(e),_.ByHeight,this.heightOracle,0,0),this.scaler)}getScrollOffset(){return(this.scrollParent==this.view.scrollDOM?this.scrollParent.scrollTop:(this.scrollParent?this.scrollParent.getBoundingClientRect().top:0)-this.view.contentDOM.getBoundingClientRect().top)*this.scaleY}scrollAnchorAt(e){let t=this.lineBlockAtHeight(e+8);return t.from>=this.viewport.from||this.viewportLines[0].top-e>200?t:this.viewportLines[0]}elementAtHeight(e){return Fi(this.heightMap.blockAt(this.scaler.fromDOM(e),this.heightOracle,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}},hi=class{constructor(e,t){this.from=e,this.to=t}};function hm(n,e,t){let i=[],s=n,r=0;return I.spans(t,n,e,{span(){},point(o,l){o>s&&(i.push({from:s,to:o}),r+=o-s),s=l}},20),s=1)return e[e.length-1].to;let i=Math.floor(n*t);for(let s=0;;s++){let{from:r,to:o}=e[s],l=o-r;if(i<=l)return r+i;i-=l}}function jn(n,e){let t=0;for(let{from:i,to:s}of n.ranges){if(e<=s){t+=e-i;break}t+=s-i}return t/n.total}function cm(n,e){for(let t of n)if(e(t))return t}var Mh={toDOM(n){return n},fromDOM(n){return n},scale:1,eq(n){return n==this}};function Th(n){let e=n.facet(ws).filter(i=>typeof i!="function"),t=n.facet(_o).filter(i=>typeof i!="function");return t.length&&e.push(I.join(t)),e}var Ao=class n{constructor(e,t,i){let s=0,r=0,o=0;this.viewports=i.map(({from:l,to:a})=>{let h=t.lineAt(l,_.ByPos,e,0,0).top,c=t.lineAt(a,_.ByPos,e,0,0).bottom;return s+=c-h,{from:l,to:a,top:h,bottom:c,domTop:0,domBottom:0}}),this.scale=(7e6-s)/(t.height-s);for(let l of this.viewports)l.domTop=o+(l.top-r)*this.scale,o=l.domBottom=l.domTop+(l.bottom-l.top),r=l.bottom}toDOM(e){for(let t=0,i=0,s=0;;t++){let r=tt.from==e.viewports[i].from&&t.to==e.viewports[i].to):!1}};function Fi(n,e){if(e.scale==1)return n;let t=e.toDOM(n.top),i=e.toDOM(n.bottom);return new qe(n.from,n.length,t,i-t,Array.isArray(n._content)?n._content.map(s=>Fi(s,e)):n._content)}var Un=C.define({combine:n=>n.join(" ")}),Mo=C.define({combine:n=>n.indexOf(!0)>-1}),To=Ie.newName(),Lc=Ie.newName(),Ec=Ie.newName(),Rc={"&light":"."+Lc,"&dark":"."+Ec};function Do(n,e,t){return new Ie(e,{finish(i){return/&/.test(i)?i.replace(/&\w*/,s=>{if(s=="&")return n;if(!t||!t[s])throw new RangeError(`Unsupported selector: ${s}`);return t[s]}):n+" "+i}})}var fm=Do("."+To,{"&":{position:"relative !important",boxSizing:"border-box","&.cm-focused":{outline:"1px dotted #212121"},display:"flex !important",flexDirection:"column"},".cm-scroller":{display:"flex !important",alignItems:"flex-start !important",fontFamily:"monospace",lineHeight:1.4,height:"100%",overflowX:"auto",position:"relative",zIndex:0,overflowAnchor:"none"},".cm-content":{margin:0,flexGrow:2,flexShrink:0,display:"block",whiteSpace:"pre",wordWrap:"normal",boxSizing:"border-box",minHeight:"100%",padding:"4px 0",outline:"none","&[contenteditable=true]":{WebkitUserModify:"read-write-plaintext-only"}},".cm-lineWrapping":{whiteSpace_fallback:"pre-wrap",whiteSpace:"break-spaces",wordBreak:"break-word",overflowWrap:"anywhere",flexShrink:1},"&light .cm-content":{caretColor:"black"},"&dark .cm-content":{caretColor:"white"},".cm-line":{display:"block",padding:"0 2px 0 6px"},".cm-layer":{userSelect:"none",position:"absolute",left:0,top:0,contain:"size style","& > *":{position:"absolute"}},"&light .cm-selectionBackground":{background:"#d9d9d9"},"&dark .cm-selectionBackground":{background:"#222"},"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#d7d4f0"},"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#233"},".cm-cursorLayer":{pointerEvents:"none"},"&.cm-focused > .cm-scroller > .cm-cursorLayer":{animation:"steps(1) cm-blink 1.2s infinite"},"@keyframes cm-blink":{"0%":{},"50%":{opacity:0},"100%":{}},"@keyframes cm-blink2":{"0%":{},"50%":{opacity:0},"100%":{}},".cm-cursor, .cm-dropCursor":{borderLeft:"1.2px solid black",marginLeft:"-0.6px",pointerEvents:"none"},".cm-cursor":{display:"none"},"&dark .cm-cursor":{borderLeftColor:"#ddd"},".cm-selectionHandle":{backgroundColor:"currentColor",width:"1.5px"},".cm-selectionHandle-start::before, .cm-selectionHandle-end::before":{content:'""',backgroundColor:"inherit",borderRadius:"50%",width:"8px",height:"8px",position:"absolute",left:"-3.25px"},".cm-selectionHandle-start::before":{top:"-8px"},".cm-selectionHandle-end::before":{bottom:"-8px"},".cm-dropCursor":{position:"absolute"},"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor":{display:"block"},".cm-iso":{unicodeBidi:"isolate"},".cm-announced":{position:"fixed",top:"-10000px"},"@media print":{".cm-announced":{display:"none"}},"&light .cm-activeLine":{backgroundColor:"#cceeff44"},"&dark .cm-activeLine":{backgroundColor:"#99eeff33"},"&light .cm-specialChar":{color:"red"},"&dark .cm-specialChar":{color:"#f78"},".cm-gutters":{flexShrink:0,display:"flex",height:"100%",boxSizing:"border-box",zIndex:200},".cm-gutters-before":{insetInlineStart:0},".cm-gutters-after":{insetInlineEnd:0},"&light .cm-gutters":{backgroundColor:"#f5f5f5",color:"#6c6c6c",border:"0px solid #ddd","&.cm-gutters-before":{borderRightWidth:"1px"},"&.cm-gutters-after":{borderLeftWidth:"1px"}},"&dark .cm-gutters":{backgroundColor:"#333338",color:"#ccc"},".cm-gutter":{display:"flex !important",flexDirection:"column",flexShrink:0,boxSizing:"border-box",minHeight:"100%",overflow:"hidden"},".cm-gutterElement":{boxSizing:"border-box"},".cm-lineNumbers .cm-gutterElement":{padding:"0 3px 0 5px",minWidth:"20px",textAlign:"right",whiteSpace:"nowrap"},"&light .cm-activeLineGutter":{backgroundColor:"#e2f2ff"},"&dark .cm-activeLineGutter":{backgroundColor:"#222227"},".cm-panels":{boxSizing:"border-box",position:"sticky",left:0,right:0,zIndex:300},"&light .cm-panels":{backgroundColor:"#f5f5f5",color:"black"},"&light .cm-panels-top":{borderBottom:"1px solid #ddd"},"&light .cm-panels-bottom":{borderTop:"1px solid #ddd"},"&dark .cm-panels":{backgroundColor:"#333338",color:"white"},".cm-dialog":{padding:"2px 19px 4px 6px",position:"relative","& label":{fontSize:"80%"}},".cm-dialog-close":{position:"absolute",top:"3px",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",fontSize:"14px",padding:"0"},".cm-tab":{display:"inline-block",overflow:"hidden",verticalAlign:"bottom"},".cm-widgetBuffer":{verticalAlign:"text-top",height:"1em",width:0,display:"inline"},".cm-placeholder":{color:"#888",display:"inline-block",verticalAlign:"top",userSelect:"none"},".cm-highlightSpace":{backgroundImage:"radial-gradient(circle at 50% 55%, #aaa 20%, transparent 5%)",backgroundPosition:"center"},".cm-highlightTab":{backgroundImage:`url('data:image/svg+xml,')`,backgroundSize:"auto 100%",backgroundPosition:"right 90%",backgroundRepeat:"no-repeat"},".cm-trailingSpace":{backgroundColor:"#ff332255"},".cm-button":{verticalAlign:"middle",color:"inherit",fontSize:"70%",padding:".2em 1em",borderRadius:"1px"},"&light .cm-button":{backgroundImage:"linear-gradient(#eff1f5, #d9d9df)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#b4b4b4, #d0d3d6)"}},"&dark .cm-button":{backgroundImage:"linear-gradient(#393939, #111)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#111, #333)"}},".cm-textfield":{verticalAlign:"middle",color:"inherit",fontSize:"70%",border:"1px solid silver",padding:".2em .5em"},"&light .cm-textfield":{backgroundColor:"white"},"&dark .cm-textfield":{border:"1px solid #555",backgroundColor:"inherit"}},Rc),um={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},zr=D.ie&&D.ie_version<=11,Oo=class{constructor(e){this.view=e,this.active=!1,this.editContext=null,this.selectionRange=new Xr,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resizeScroll=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.printQuery=null,this.parentCheck=-1,this.dom=e.contentDOM,this.observer=new MutationObserver(t=>{for(let i of t)this.queue.push(i);(D.ie&&D.ie_version<=11||D.ios&&e.composing)&&t.some(i=>i.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():this.flush()}),window.EditContext&&D.android&&e.constructor.EDIT_CONTEXT!==!1&&!(D.chrome&&D.chrome_version<126)&&(this.editContext=new Bo(e),e.state.facet(at)&&(e.contentDOM.editContext=this.editContext.editContext)),zr&&(this.onCharData=t=>{this.queue.push({target:t.target,type:"characterData",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),window.matchMedia&&(this.printQuery=window.matchMedia("print")),typeof ResizeObserver=="function"&&(this.resizeScroll=new ResizeObserver(()=>{var t;((t=this.view.docView)===null||t===void 0?void 0:t.lastUpdate){this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent("Event")))},{threshold:[0,.001]}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver(t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent("Event"))},{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(e){this.view.inputState.runHandlers("scroll",e),this.intersecting&&this.view.measure()}onScroll(e){this.intersecting&&this.flush(!1),this.editContext&&this.view.requestMeasure(this.editContext.measureReq),this.onScrollChanged(e)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout(()=>{this.resizeTimeout=-1,this.view.requestMeasure()},50))}onPrint(e){(e.type=="change"||!e.type)&&!e.matches||(this.view.viewState.printing=!0,this.view.measure(),setTimeout(()=>{this.view.viewState.printing=!1,this.view.requestMeasure()},500))}updateGaps(e){if(this.gapIntersection&&(e.length!=this.gaps.length||this.gaps.some((t,i)=>t!=e[i]))){this.gapIntersection.disconnect();for(let t of e)this.gapIntersection.observe(t);this.gaps=e}}onSelectionChange(e){let t=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:i}=this,s=this.selectionRange;if(i.state.facet(at)?i.root.activeElement!=this.dom:!Wi(this.dom,s))return;let r=s.anchorNode&&i.docView.tile.nearest(s.anchorNode);if(r&&r.isWidget()&&r.widget.ignoreEvent(e)){t||(this.selectionChanged=!1);return}(D.ie&&D.ie_version<=11||D.android&&D.chrome)&&!i.state.selection.main.empty&&s.focusNode&&Vi(s.focusNode,s.focusOffset,s.anchorNode,s.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:e}=this,t=Ji(e.root);if(!t)return!1;let i=D.safari&&e.root.nodeType==11&&e.root.activeElement==this.dom&&dm(this.view,t)||t;if(!i||this.selectionRange.eq(i))return!1;let s=Wi(this.dom,i);return s&&!this.selectionChanged&&e.inputState.lastFocusTime>Date.now()-200&&e.inputState.lastTouchTime{let r=this.delayedAndroidKey;r&&(this.clearDelayedAndroidKey(),this.view.inputState.lastKeyCode=r.keyCode,this.view.inputState.lastKeyTime=Date.now(),!this.flush()&&r.force&&di(this.dom,r.key,r.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(s)}(!this.delayedAndroidKey||e=="Enter")&&(this.delayedAndroidKey={key:e,keyCode:t,force:this.lastChange{this.delayedFlush=-1,this.flush()}))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}pendingRecords(){for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}processRecords(){let e=this.pendingRecords();e.length&&(this.queue=[]);let t=-1,i=-1,s=!1;for(let r of e){let o=this.readMutation(r);o&&(o.typeOver&&(s=!0),t==-1?{from:t,to:i}=o:(t=Math.min(o.from,t),i=Math.max(o.to,i)))}return{from:t,to:i,typeOver:s}}readChange(){let{from:e,to:t,typeOver:i}=this.processRecords(),s=this.selectionChanged&&Wi(this.dom,this.selectionRange);if(e<0&&!s)return null;e>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1;let r=new mo(this.view,e,t,i);return this.view.docView.domChanged={newSel:r.newSel?r.newSel.main:null},r}flush(e=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;e&&this.readSelectionRange();let t=this.readChange();if(!t)return this.view.requestMeasure(),!1;let i=this.view.state,s=vc(this.view,t);return this.view.state==i&&(t.domChanged||t.newSel&&!hs(this.view.state.selection,t.newSel.main))&&this.view.update([]),s}readMutation(e){let t=this.view.docView.tile.nearest(e.target);if(!t||t.isWidget())return null;if(t.markDirty(e.type=="attributes"),e.type=="childList"){let i=Dh(t,e.previousSibling||e.target.previousSibling,-1),s=Dh(t,e.nextSibling||e.target.nextSibling,1);return{from:i?t.posAfter(i):t.posAtStart,to:s?t.posBefore(s):t.posAtEnd,typeOver:!1}}else return e.type=="characterData"?{from:t.posAtStart,to:t.posAtEnd,typeOver:e.target.nodeValue==e.oldValue}:null}setWindow(e){e!=this.win&&(this.removeWindowListeners(this.win),this.win=e,this.addWindowListeners(this.win))}addWindowListeners(e){e.addEventListener("resize",this.onResize),this.printQuery?this.printQuery.addEventListener?this.printQuery.addEventListener("change",this.onPrint):this.printQuery.addListener(this.onPrint):e.addEventListener("beforeprint",this.onPrint),e.addEventListener("scroll",this.onScroll),e.document.addEventListener("selectionchange",this.onSelectionChange)}removeWindowListeners(e){e.removeEventListener("scroll",this.onScroll),e.removeEventListener("resize",this.onResize),this.printQuery?this.printQuery.removeEventListener?this.printQuery.removeEventListener("change",this.onPrint):this.printQuery.removeListener(this.onPrint):e.removeEventListener("beforeprint",this.onPrint),e.document.removeEventListener("selectionchange",this.onSelectionChange)}update(e){this.editContext&&(this.editContext.update(e),e.startState.facet(at)!=e.state.facet(at)&&(e.view.contentDOM.editContext=e.state.facet(at)?this.editContext.editContext:null))}destroy(){var e,t,i;this.stop(),(e=this.intersection)===null||e===void 0||e.disconnect(),(t=this.gapIntersection)===null||t===void 0||t.disconnect(),(i=this.resizeScroll)===null||i===void 0||i.disconnect();for(let s of this.scrollTargets)s.removeEventListener("scroll",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey),this.editContext&&(this.view.contentDOM.editContext=null,this.editContext.destroy())}};function Dh(n,e,t){for(;e;){let i=X.get(e);if(i&&i.parent==n)return i;let s=e.parentNode;e=s!=n.dom?s:t>0?e.nextSibling:e.previousSibling}return null}function Oh(n,e){let t=e.startContainer,i=e.startOffset,s=e.endContainer,r=e.endOffset,o=n.docView.domAtPos(n.state.selection.main.anchor,1);return Vi(o.node,o.offset,s,r)&&([t,i,s,r]=[s,r,t,i]),{anchorNode:t,anchorOffset:i,focusNode:s,focusOffset:r}}function dm(n,e){if(e.getComposedRanges){let s=e.getComposedRanges(n.root)[0];if(s)return Oh(n,s)}let t=null;function i(s){s.preventDefault(),s.stopImmediatePropagation(),t=s.getTargetRanges()[0]}return n.contentDOM.addEventListener("beforeinput",i,!0),n.dom.ownerDocument.execCommand("indent"),n.contentDOM.removeEventListener("beforeinput",i,!0),t?Oh(n,t):null}var Bo=class{constructor(e){this.from=0,this.to=0,this.pendingContextChange=null,this.handlers=Object.create(null),this.composing=null,this.resetRange(e.state);let t=this.editContext=new window.EditContext({text:e.state.doc.sliceString(this.from,this.to),selectionStart:this.toContextPos(Math.max(this.from,Math.min(this.to,e.state.selection.main.anchor))),selectionEnd:this.toContextPos(e.state.selection.main.head)});this.handlers.textupdate=i=>{let s=e.state.selection.main,{anchor:r,head:o}=s,l=this.toEditorPos(i.updateRangeStart),a=this.toEditorPos(i.updateRangeEnd);e.inputState.composing>=0&&!this.composing&&(this.composing={contextBase:i.updateRangeStart,editorBase:l,drifted:!1});let h=a-l>i.text.length;l==this.from&&rthis.to&&(a=r);let c=kc(e.state.sliceDoc(l,a),i.text,(h?s.from:s.to)-l,h?"end":null);if(!c){let u=b.single(this.toEditorPos(i.selectionStart),this.toEditorPos(i.selectionEnd));hs(u,s)||e.dispatch({selection:u,userEvent:"select"});return}let f={from:c.from+l,to:c.toA+l,insert:F.of(i.text.slice(c.from,c.toB).split(` +`))};if((D.mac||D.android)&&f.from==o-1&&/^\. ?$/.test(i.text)&&e.contentDOM.getAttribute("autocorrect")=="off"&&(f={from:l,to:a,insert:F.of([i.text.replace("."," ")])}),this.pendingContextChange=f,!e.state.readOnly){let u=this.to-this.from+(f.to-f.from+f.insert.length);Jo(e,f,b.single(this.toEditorPos(i.selectionStart,u),this.toEditorPos(i.selectionEnd,u)))}this.pendingContextChange&&(this.revertPending(e.state),this.setSelection(e.state)),f.from=0&&!/[\\p{Alphabetic}\\p{Number}_]/.test(t.text.slice(Math.max(0,i.updateRangeStart-1),Math.min(t.text.length,i.updateRangeStart+1)))&&this.handlers.compositionend(i)},this.handlers.characterboundsupdate=i=>{let s=[],r=null;for(let o=this.toEditorPos(i.rangeStart),l=this.toEditorPos(i.rangeEnd);o{let s=[];for(let r of i.getTextFormats()){let o=r.underlineStyle,l=r.underlineThickness;if(!/none/i.test(o)&&!/none/i.test(l)){let a=this.toEditorPos(r.rangeStart),h=this.toEditorPos(r.rangeEnd);if(a{e.inputState.composing<0&&(e.inputState.composing=0,e.inputState.compositionFirstChange=!0)},this.handlers.compositionend=()=>{if(e.inputState.composing=-1,e.inputState.compositionFirstChange=null,this.composing){let{drifted:i}=this.composing;this.composing=null,i&&this.reset(e.state)}};for(let i in this.handlers)t.addEventListener(i,this.handlers[i]);this.measureReq={read:i=>{let s=Ji(i.root);s&&s.rangeCount&&this.editContext.updateSelectionBounds(s.getRangeAt(0).getBoundingClientRect())}}}applyEdits(e){let t=0,i=!1,s=this.pendingContextChange;return e.changes.iterChanges((r,o,l,a,h)=>{if(i)return;let c=h.length-(o-r);if(s&&o>=s.to)if(s.from==r&&s.to==o&&s.insert.eq(h)){s=this.pendingContextChange=null,t+=c,this.to+=c;return}else s=null,this.revertPending(e.state);if(r+=t,o+=t,o<=this.from)this.from+=c,this.to+=c;else if(rthis.to||this.to-this.from+h.length>3e4){i=!0;return}this.editContext.updateText(this.toContextPos(r),this.toContextPos(o),h.toString()),this.to+=c}t+=c}),s&&!i&&this.revertPending(e.state),!i}update(e){let t=this.pendingContextChange,i=e.startState.selection.main;this.composing&&(this.composing.drifted||!e.changes.touchesRange(i.from,i.to)&&e.transactions.some(s=>!s.isUserEvent("input.type")&&s.changes.touchesRange(this.from,this.to)))?(this.composing.drifted=!0,this.composing.editorBase=e.changes.mapPos(this.composing.editorBase)):!this.applyEdits(e)||!this.rangeIsValid(e.state)?(this.pendingContextChange=null,this.reset(e.state)):(e.docChanged||e.selectionSet||t)&&this.setSelection(e.state),(e.geometryChanged||e.docChanged||e.selectionSet)&&e.view.requestMeasure(this.measureReq)}resetRange(e){let{head:t}=e.selection.main;this.from=Math.max(0,t-1e4),this.to=Math.min(e.doc.length,t+1e4)}reset(e){this.resetRange(e),this.editContext.updateText(0,this.editContext.text.length,e.doc.sliceString(this.from,this.to)),this.setSelection(e)}revertPending(e){let t=this.pendingContextChange;this.pendingContextChange=null,this.editContext.updateText(this.toContextPos(t.from),this.toContextPos(t.from+t.insert.length),e.doc.sliceString(t.from,t.to))}setSelection(e){let{main:t}=e.selection,i=this.toContextPos(Math.max(this.from,Math.min(this.to,t.anchor))),s=this.toContextPos(t.head);(this.editContext.selectionStart!=i||this.editContext.selectionEnd!=s)&&this.editContext.updateSelection(i,s)}rangeIsValid(e){let{head:t}=e.selection.main;return!(this.from>0&&t-this.from<500||this.to1e4*3)}toEditorPos(e,t=this.to-this.from){e=Math.min(e,t);let i=this.composing;return i&&i.drifted?i.editorBase+(e-i.contextBase):e+this.from}toContextPos(e){let t=this.composing;return t&&t.drifted?t.contextBase+(e-t.editorBase):e-this.from}destroy(){for(let e in this.handlers)this.editContext.removeEventListener(e,this.handlers[e])}},A=class n{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return!!this.inputState&&this.inputState.composing>0}get compositionStarted(){return!!this.inputState&&this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(e={}){var t;this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.contentDOM=document.createElement("div"),this.scrollDOM=document.createElement("div"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className="cm-scroller",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement("div"),this.announceDOM.className="cm-announced",this.announceDOM.setAttribute("aria-live","polite"),this.dom=document.createElement("div"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM),e.parent&&e.parent.appendChild(this.dom);let{dispatch:i}=e;this.dispatchTransactions=e.dispatchTransactions||i&&(s=>s.forEach(r=>i(r,this)))||(s=>this.update(s)),this.dispatch=this.dispatch.bind(this),this._root=e.root||op(e.parent)||document,this.viewState=new us(this,e.state||j.create(e)),e.scrollTo&&e.scrollTo.is(zn)&&(this.viewState.scrollTarget=e.scrollTo.value.clip(this.viewState.state)),this.plugins=this.state.facet(ai).map(s=>new qi(s));for(let s of this.plugins)s.update(this);this.observer=new Oo(this),this.inputState=new go(this),this.inputState.ensureHandlers(this.plugins),this.docView=new ls(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),!((t=document.fonts)===null||t===void 0)&&t.ready&&document.fonts.ready.then(()=>{this.viewState.mustMeasureContent="refresh",this.requestMeasure()})}dispatch(...e){let t=e.length==1&&e[0]instanceof ie?e:e.length==1&&Array.isArray(e[0])?e[0]:[this.state.update(...e)];this.dispatchTransactions(t,this)}update(e){if(this.updateState!=0)throw new Error("Calls to EditorView.update are not allowed while an update is in progress");let t=!1,i=!1,s,r=this.state;for(let u of e){if(u.startState!=r)throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.");r=u.state}if(this.destroyed){this.viewState.state=r;return}let o=this.hasFocus,l=0,a=null;e.some(u=>u.annotation(Dc))?(this.inputState.notifiedFocused=o,l=1):o!=this.inputState.notifiedFocused&&(this.inputState.notifiedFocused=o,a=Oc(r,o),a||(l=1));let h=this.observer.delayedAndroidKey,c=null;if(h?(this.observer.clearDelayedAndroidKey(),c=this.observer.readChange(),(c&&!this.state.doc.eq(r.doc)||!this.state.selection.eq(r.selection))&&(c=null)):this.observer.clear(),r.facet(j.phrases)!=this.state.facet(j.phrases))return this.setState(r);s=rs.create(this,r,e),s.flags|=l;let f=this.viewState.scrollTarget;try{this.updateState=2;for(let u of e){if(f&&(f=f.map(u.changes)),u.scrollIntoView){let{main:d}=u.state.selection,{x:p,y:m}=this.state.facet(n.cursorScrollMargin);f=new zi(d.empty?d:b.cursor(d.head,d.head>d.anchor?-1:1),"nearest","nearest",m,p)}for(let d of u.effects)d.is(zn)&&(f=d.value.clip(this.state))}this.viewState.update(s,f),this.bidiCache=ds.update(this.bidiCache,s.changes),s.empty||(this.updatePlugins(s),this.inputState.update(s)),t=this.docView.update(s),this.state.facet(Ni)!=this.styleModules&&this.mountStyles(),i=this.updateAttrs(),this.showAnnouncements(e),this.docView.updateSelection(t,e.some(u=>u.isUserEvent("select.pointer")))}finally{this.updateState=0}if(s.startState.facet(Un)!=s.state.facet(Un)&&(this.viewState.mustMeasureContent=!0),(t||i||f||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),t&&this.docViewUpdate(),!s.empty)for(let u of this.state.facet(to))try{u(s)}catch(d){re(this.state,d,"update listener")}(a||c)&&Promise.resolve().then(()=>{a&&this.state==a.startState&&this.dispatch(a),c&&!vc(this,c)&&h.force&&di(this.contentDOM,h.key,h.keyCode)})}setState(e){if(this.updateState!=0)throw new Error("Calls to EditorView.setState are not allowed while an update is in progress");if(this.destroyed){this.viewState.state=e;return}this.updateState=2;let t=this.hasFocus;try{for(let i of this.plugins)i.destroy(this);this.viewState=new us(this,e),this.plugins=e.facet(ai).map(i=>new qi(i)),this.pluginMap.clear();for(let i of this.plugins)i.update(this);this.docView.destroy(),this.docView=new ls(this),this.inputState.ensureHandlers(this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}t&&this.focus(),this.requestMeasure()}updatePlugins(e){let t=e.startState.facet(ai),i=e.state.facet(ai);if(t!=i){let s=[];for(let r of i){let o=t.indexOf(r);if(o<0)s.push(new qi(r));else{let l=this.plugins[o];l.mustUpdate=e,s.push(l)}}for(let r of this.plugins)r.mustUpdate!=e&&r.destroy(this);this.plugins=s,this.pluginMap.clear()}else for(let s of this.plugins)s.mustUpdate=e;for(let s=0;s-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.observer.delayedAndroidKey){this.measureScheduled=-1,this.requestMeasure();return}this.measureScheduled=0,e&&this.observer.forceFlush();let t=null,i=this.viewState.scrollParent,s=this.viewState.getScrollOffset(),{scrollAnchorPos:r,scrollAnchorHeight:o}=this.viewState;Math.abs(s-this.viewState.scrollOffset)>1&&(o=-1),this.viewState.scrollAnchorHeight=-1;try{for(let l=0;;l++){if(o<0)if(Yh(i||this.win))r=-1,o=this.viewState.heightMap.height;else{let d=this.viewState.scrollAnchorAt(s);r=d.from,o=d.top}this.updateState=1;let a=this.viewState.measure();if(!a&&!this.measureRequests.length&&this.viewState.scrollTarget==null)break;if(l>5){console.warn(this.measureRequests.length?"Measure loop restarted more than 5 times":"Viewport failed to stabilize");break}let h=[];a&4||([this.measureRequests,h]=[h,this.measureRequests]);let c=h.map(d=>{try{return d.read(this)}catch(p){return re(this.state,p),Bh}}),f=rs.create(this,this.state,[]),u=!1;f.flags|=a,t?t.flags|=a:t=f,this.updateState=2,f.empty||(this.updatePlugins(f),this.inputState.update(f),this.updateAttrs(),u=this.docView.update(f),u&&this.docViewUpdate());for(let d=0;d1||p<-1)&&!(D.ios&&this.inputState.lastIOSMomentumScroll>Date.now()-100)&&(i==this.scrollDOM||this.hasFocus||Math.max(this.inputState.lastWheelEvent,this.inputState.lastTouchTime)>Date.now()-100)){s=s+p,i?i.scrollTop+=p:this.win.scrollBy(0,p),o=-1;continue}}break}}}finally{this.updateState=0,this.measureScheduled=-1}if(t&&!t.empty)for(let l of this.state.facet(to))l(t)}get themeClasses(){return To+" "+(this.state.facet(Mo)?Ec:Lc)+" "+this.state.facet(Un)}updateAttrs(){let e=Lh(this,dc,{class:"cm-editor"+(this.hasFocus?" cm-focused ":" ")+this.themeClasses}),t={spellcheck:"false",autocorrect:"off",autocapitalize:"off",writingsuggestions:"false",translate:"no",contenteditable:this.state.facet(at)?"true":"false",class:"cm-content",style:`${D.tabSize}: ${this.state.tabSize}`,role:"textbox","aria-multiline":"true"};this.state.readOnly&&(t["aria-readonly"]="true"),Lh(this,Go,t);let i=this.observer.ignore(()=>{let s=hh(this.contentDOM,this.contentAttrs,t),r=hh(this.dom,this.editorAttrs,e);return s||r});return this.editorAttrs=e,this.contentAttrs=t,i}showAnnouncements(e){let t=!0;for(let i of e)for(let s of i.effects)if(s.is(n.announce)){t&&(this.announceDOM.textContent=""),t=!1;let r=this.announceDOM.appendChild(document.createElement("div"));r.textContent=s.value}}mountStyles(){this.styleModules=this.state.facet(Ni);let e=this.state.facet(n.cspNonce);Ie.mount(this.root,this.styleModules.concat(fm).reverse(),e?{nonce:e}:void 0)}readMeasured(){if(this.updateState==2)throw new Error("Reading the editor layout isn't allowed during an update");this.updateState==0&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(e){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame(()=>this.measure())),e){if(this.measureRequests.indexOf(e)>-1)return;if(e.key!=null){for(let t=0;ti.plugin==e)||null),t&&t.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}get scaleX(){return this.viewState.scaleX}get scaleY(){return this.viewState.scaleY}elementAtHeight(e){return this.readMeasured(),this.viewState.elementAtHeight(e)}lineBlockAtHeight(e){return this.readMeasured(),this.viewState.lineBlockAtHeight(e)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(e){return this.viewState.lineBlockAt(e)}get contentHeight(){return this.viewState.contentHeight}moveByChar(e,t,i){return Vr(this,e,mh(this,e,t,i))}moveByGroup(e,t){return Vr(this,e,mh(this,e,t,i=>Ip(this,e.head,i)))}visualLineSide(e,t){let i=this.bidiSpans(e),s=this.textDirectionAt(e.from),r=i[t?i.length-1:0];return b.cursor(r.side(t,s)+e.from,r.forward(!t,s)?1:-1)}moveToLineBoundary(e,t,i=!0){return Pp(this,e,t,i)}moveVertically(e,t,i){return Vr(this,e,Np(this,e,t,i))}domAtPos(e,t=1){return this.docView.domAtPos(e,t)}posAtDOM(e,t=0){return this.docView.posFromDOM(e,t)}posAtCoords(e,t=!0){this.readMeasured();let i=fo(this,e,t);return i&&i.pos}posAndSideAtCoords(e,t=!0){return this.readMeasured(),fo(this,e,t)}coordsAtPos(e,t=1){this.readMeasured();let i=this.state.doc.lineAt(e),s=this.bidiSpans(i),r=s[He.find(s,e-i.from,-1,t)];return this.docView.coordsAt(e,t,r.dir==V.RTL)}coordsForChar(e){return this.readMeasured(),this.docView.coordsForChar(e)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(e){return!this.state.facet(hc)||ethis.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(e))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(e){if(e.length>pm)return tc(e.length);let t=this.textDirectionAt(e.from),i;for(let r of this.bidiCache)if(r.from==e.from&&r.dir==t&&(r.fresh||ec(r.isolates,i=uh(this,e))))return r.order;i||(i=uh(this,e));let s=pp(e.text,t,i);return this.bidiCache.push(new ds(e.from,e.to,t,i,!0,s)),s}get hasFocus(){var e;return(this.dom.ownerDocument.hasFocus()||D.safari&&((e=this.inputState)===null||e===void 0?void 0:e.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore(()=>{_h(this.contentDOM),this.docView.updateSelection()})}setRoot(e){this._root!=e&&(this._root=e,this.observer.setWindow((e.nodeType==9?e:e.ownerDocument).defaultView||window),this.mountStyles())}destroy(){this.root.activeElement==this.contentDOM&&this.contentDOM.blur();for(let e of this.plugins)e.destroy(this);this.plugins=[],this.inputState.destroy(),this.docView.destroy(),this.dom.remove(),this.observer.destroy(),this.measureScheduled>-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(e,t={}){var i,s,r,o;return zn.of(new zi(typeof e=="number"?b.cursor(e):e,(i=t.y)!==null&&i!==void 0?i:"nearest",(s=t.x)!==null&&s!==void 0?s:"nearest",(r=t.yMargin)!==null&&r!==void 0?r:5,(o=t.xMargin)!==null&&o!==void 0?o:5))}scrollSnapshot(){let{scrollTop:e,scrollLeft:t}=this.scrollDOM,i=this.viewState.scrollAnchorAt(e);return zn.of(new zi(b.cursor(i.from),"start","start",i.top-e,t,!0))}setTabFocusMode(e){e==null?this.inputState.tabFocusMode=this.inputState.tabFocusMode<0?0:-1:typeof e=="boolean"?this.inputState.tabFocusMode=e?0:-1:this.inputState.tabFocusMode!=0&&(this.inputState.tabFocusMode=Date.now()+e)}static domEventHandlers(e){return Y.define(()=>({}),{eventHandlers:e})}static domEventObservers(e){return Y.define(()=>({}),{eventObservers:e})}static theme(e,t){let i=Ie.newName(),s=[Un.of(i),Ni.of(Do(`.${i}`,e))];return t&&t.dark&&s.push(Mo.of(!0)),s}static baseTheme(e){return Oe.lowest(Ni.of(Do("."+To,e,Rc)))}static findFromDOM(e){var t;let i=e.querySelector(".cm-content"),s=i&&X.get(i)||X.get(e);return((t=s?.root)===null||t===void 0?void 0:t.view)||null}};A.styleModule=Ni;A.inputHandler=lc;A.clipboardInputFilter=jo;A.clipboardOutputFilter=Uo;A.scrollHandler=fc;A.focusChangeEffect=ac;A.perLineTextDirection=hc;A.exceptionSink=oc;A.updateListener=to;A.editable=at;A.mouseSelectionStyle=rc;A.dragMovesSelection=sc;A.clickAddsSelectionRange=nc;A.decorations=ws;A.blockWrappers=pc;A.outerDecorations=_o;A.atomicRanges=Qi;A.bidiIsolatedRanges=mc;A.cursorScrollMargin=C.define({combine:n=>{let e=5,t=5;for(let i of n)typeof i=="number"?e=t=i:{x:e,y:t}=i;return{x:e,y:t}}});A.scrollMargins=gc;A.darkTheme=Mo;A.cspNonce=C.define({combine:n=>n.length?n[0]:""});A.contentAttributes=Go;A.editorAttributes=dc;A.lineWrapping=A.contentAttributes.of({class:"cm-lineWrapping"});A.announce=L.define();var pm=4096,Bh={},ds=class n{constructor(e,t,i,s,r,o){this.from=e,this.to=t,this.dir=i,this.isolates=s,this.fresh=r,this.order=o}static update(e,t){if(t.empty&&!e.some(r=>r.fresh))return e;let i=[],s=e.length?e[e.length-1].dir:V.LTR;for(let r=Math.max(0,e.length-10);r=0;s--){let r=i[s],o=typeof r=="function"?r(n):r;o&&qo(o,t)}return t}var mm=D.mac?"mac":D.windows?"win":D.linux?"linux":"key";function gm(n,e){let t=n.split(/-(?!$)/),i=t[t.length-1];i=="Space"&&(i=" ");let s,r,o,l;for(let a=0;ai.concat(s),[]))),t}function Ic(n,e,t){return Nc(Pc(n.state),e,n,t)}var yt=null,bm=4e3;function xm(n,e=mm){let t=Object.create(null),i=Object.create(null),s=(o,l)=>{let a=i[o];if(a==null)i[o]=l;else if(a!=l)throw new Error("Key binding "+o+" is used both as a regular binding and as a multi-stroke prefix")},r=(o,l,a,h,c)=>{var f,u;let d=t[o]||(t[o]=Object.create(null)),p=l.split(/ (?!$)/).map(y=>gm(y,e));for(let y=1;y{let O=yt={view:w,prefix:x,scope:o};return setTimeout(()=>{yt==O&&(yt=null)},bm),!0}]})}let m=p.join(" ");s(m,!1);let g=d[m]||(d[m]={preventDefault:!1,stopPropagation:!1,run:((u=(f=d._any)===null||f===void 0?void 0:f.run)===null||u===void 0?void 0:u.slice())||[]});a&&g.run.push(a),h&&(g.preventDefault=!0),c&&(g.stopPropagation=!0)};for(let o of n){let l=o.scope?o.scope.split(" "):["editor"];if(o.any)for(let h of l){let c=t[h]||(t[h]=Object.create(null));c._any||(c._any={preventDefault:!1,stopPropagation:!1,run:[]});let{any:f}=o;for(let u in c)c[u].run.push(d=>f(d,Lo))}let a=o[e]||o.key;if(a)for(let h of l)r(h,a,o.run,o.preventDefault,o.stopPropagation),o.shift&&r(h,"Shift-"+a,o.shift,o.preventDefault,o.stopPropagation)}return t}var Lo=null;function Nc(n,e,t,i){Lo=e;let s=sh(e),r=he(s,0),o=De(r)==s.length&&s!=" ",l="",a=!1,h=!1,c=!1;yt&&yt.view==t&&yt.scope==i&&(l=yt.prefix+" ",Cc.indexOf(e.keyCode)<0&&(h=!0,yt=null));let f=new Set,u=g=>{if(g){for(let y of g.run)if(!f.has(y)&&(f.add(y),y(t)))return g.stopPropagation&&(c=!0),!0;g.preventDefault&&(g.stopPropagation&&(c=!0),h=!0)}return!1},d=n[i],p,m;return d&&(u(d[l+Gn(s,e,!o)])?a=!0:o&&(e.altKey||e.metaKey||e.ctrlKey)&&!(D.windows&&e.ctrlKey&&e.altKey)&&!(D.mac&&e.altKey&&!(e.ctrlKey||e.metaKey))&&(p=lt[e.keyCode])&&p!=s?(u(d[l+Gn(p,e,!0)])||e.shiftKey&&(m=oi[e.keyCode])!=s&&m!=p&&u(d[l+Gn(m,e,!1)]))&&(a=!0):o&&e.shiftKey&&u(d[l+Gn(s,e,!0)])&&(a=!0),!a&&u(d._any)&&(a=!0)),h&&(a=!0),a&&c&&e.stopPropagation(),Lo=null,a}var Ht=class n{constructor(e,t,i,s,r){this.className=e,this.left=t,this.top=i,this.width=s,this.height=r}draw(){let e=document.createElement("div");return e.className=this.className,this.adjust(e),e}update(e,t){return t.className!=this.className?!1:(this.adjust(e),!0)}adjust(e){e.style.left=this.left+"px",e.style.top=this.top+"px",this.width!=null&&(e.style.width=this.width+"px"),e.style.height=this.height+"px"}eq(e){return this.left==e.left&&this.top==e.top&&this.width==e.width&&this.height==e.height&&this.className==e.className}static forRange(e,t,i){if(i.empty){let s=e.coordsAtPos(i.head,i.assoc||1);if(!s)return[];let r=Fc(e);return[new n(t,s.left-r.left,s.top-r.top,null,s.bottom-s.top)]}else return wm(e,t,i)}};function Fc(n){let e=n.scrollDOM.getBoundingClientRect();return{left:(n.textDirection==V.LTR?e.left:e.right-n.scrollDOM.clientWidth*n.scaleX)-n.scrollDOM.scrollLeft*n.scaleX,top:e.top-n.scrollDOM.scrollTop*n.scaleY}}function Rh(n,e,t,i){let s=n.coordsAtPos(e,t*2);if(!s)return i;let r=n.dom.getBoundingClientRect(),o=(s.top+s.bottom)/2,l=n.posAtCoords({x:r.left+1,y:o}),a=n.posAtCoords({x:r.right-1,y:o});return l==null||a==null?i:{from:Math.max(i.from,Math.min(l,a)),to:Math.min(i.to,Math.max(l,a))}}function wm(n,e,t){if(t.to<=n.viewport.from||t.from>=n.viewport.to)return[];let i=Math.max(t.from,n.viewport.from),s=Math.min(t.to,n.viewport.to),r=n.textDirection==V.LTR,o=n.contentDOM,l=o.getBoundingClientRect(),a=Fc(n),h=o.querySelector(".cm-line"),c=h&&window.getComputedStyle(h),f=l.left+(c?parseInt(c.paddingLeft)+Math.min(0,parseInt(c.textIndent)):0),u=l.right-(c?parseInt(c.paddingRight):0),d=co(n,i,1),p=co(n,s,-1),m=d.type==fe.Text?d:null,g=p.type==fe.Text?p:null;if(m&&(n.lineWrapping||d.widgetLineBreaks)&&(m=Rh(n,i,1,m)),g&&(n.lineWrapping||p.widgetLineBreaks)&&(g=Rh(n,s,-1,g)),m&&g&&m.from==g.from&&m.to==g.to)return x(w(t.from,t.to,m));{let v=m?w(t.from,null,m):O(d,!1),k=g?w(null,t.to,g):O(p,!0),S=[];return(m||d).to<(g||p).from-(m&&g?1:0)||d.widgetLineBreaks>1&&v.bottom+n.defaultLineHeight/2E&&z.from=ge)break;Ee>G&&N(Math.max(ae,G),v==null&&ae<=E,Math.min(Ee,ge),k==null&&Ee>=q,Ye.dir)}if(G=Me.to+1,G>=ge)break}return $.length==0&&N(E,v==null,q,k==null,n.textDirection),{top:R,bottom:H,horizontal:$}}function O(v,k){let S=l.top+(k?v.top:v.bottom);return{top:S,bottom:S,horizontal:[]}}}function vm(n,e){return n.constructor==e.constructor&&n.eq(e)}var Eo=class{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.scaleX=1,this.scaleY=1,this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.scale(),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(Qn)!=e.state.facet(Qn)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&(this.scale(),e.view.requestMeasure(this.measureReq))}docViewUpdate(e){this.layer.updateOnDocViewUpdate!==!1&&e.requestMeasure(this.measureReq)}setOrder(e){let t=0,i=e.facet(Qn);for(;t!vm(t,this.drawn[i]))){let t=this.dom.firstChild,i=0;for(let s of e)s.update&&t&&s.constructor&&this.drawn[i].constructor&&s.update(t,this.drawn[i])?(t=t.nextSibling,i++):this.dom.insertBefore(s.draw(),t);for(;t;){let s=t.nextSibling;t.remove(),t=s}this.drawn=e,D.webkit&&(this.dom.style.display=this.dom.firstChild?"":"none")}}destroy(){this.layer.destroy&&this.layer.destroy(this.dom,this.view),this.dom.remove()}},Qn=C.define();function Hc(n){return[Y.define(e=>new Eo(e,n)),Qn.of(n)]}var xi=C.define({combine(n){return ce(n,{cursorBlinkRate:1200,drawRangeCursor:!0,iosSelectionHandles:!0},{cursorBlinkRate:(e,t)=>Math.min(e,t),drawRangeCursor:(e,t)=>e||t})}});function Xo(n={}){return[xi.of(n),km,Sm,Cm,cc.of(!0)]}function Wc(n){return n.startState.facet(xi)!=n.state.facet(xi)}var km=Hc({above:!0,markers(n){let{state:e}=n,t=e.facet(xi),i=[];for(let s of e.selection.ranges){let r=s==e.selection.main;if(s.empty||t.drawRangeCursor&&!(r&&D.ios&&t.iosSelectionHandles)){let o=r?"cm-cursor cm-cursor-primary":"cm-cursor cm-cursor-secondary",l=s.empty?s:b.cursor(s.head,s.assoc);for(let a of Ht.forRange(n,o,l))i.push(a)}}return i},update(n,e){n.transactions.some(i=>i.selection)&&(e.style.animationName=e.style.animationName=="cm-blink"?"cm-blink2":"cm-blink");let t=Wc(n);return t&&Ph(n.state,e),n.docChanged||n.selectionSet||t},mount(n,e){Ph(e.state,n)},class:"cm-cursorLayer"});function Ph(n,e){e.style.animationDuration=n.facet(xi).cursorBlinkRate+"ms"}var Sm=Hc({above:!1,markers(n){let e=[],{main:t,ranges:i}=n.state.selection;for(let s of i)if(!s.empty)for(let r of Ht.forRange(n,"cm-selectionBackground",s))e.push(r);if(D.ios&&!t.empty&&n.state.facet(xi).iosSelectionHandles){for(let s of Ht.forRange(n,"cm-selectionHandle cm-selectionHandle-start",b.cursor(t.from,1)))e.push(s);for(let s of Ht.forRange(n,"cm-selectionHandle cm-selectionHandle-end",b.cursor(t.to,1)))e.push(s)}return e},update(n,e){return n.docChanged||n.selectionSet||n.viewportChanged||Wc(n)},class:"cm-selectionLayer"}),Cm=Oe.highest(A.theme({".cm-line":{"& ::selection, &::selection":{backgroundColor:"transparent !important"},caretColor:"transparent !important"},".cm-content":{caretColor:"transparent !important","& :focus":{caretColor:"initial !important","&::selection, & ::selection":{backgroundColor:"Highlight !important"}}}})),Vc=L.define({map(n,e){return n==null?null:e.mapPos(n)}}),Hi=J.define({create(){return null},update(n,e){return n!=null&&(n=e.changes.mapPos(n)),e.effects.reduce((t,i)=>i.is(Vc)?i.value:t,n)}}),Am=Y.fromClass(class{constructor(n){this.view=n,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(n){var e;let t=n.state.field(Hi);t==null?this.cursor!=null&&((e=this.cursor)===null||e===void 0||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement("div")),this.cursor.className="cm-dropCursor"),(n.startState.field(Hi)!=t||n.docChanged||n.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let{view:n}=this,e=n.state.field(Hi),t=e!=null&&n.coordsAtPos(e);if(!t)return null;let i=n.scrollDOM.getBoundingClientRect();return{left:t.left-i.left+n.scrollDOM.scrollLeft*n.scaleX,top:t.top-i.top+n.scrollDOM.scrollTop*n.scaleY,height:t.bottom-t.top}}drawCursor(n){if(this.cursor){let{scaleX:e,scaleY:t}=this.view;n?(this.cursor.style.left=n.left/e+"px",this.cursor.style.top=n.top/t+"px",this.cursor.style.height=n.height/t+"px"):this.cursor.style.left="-100000px"}}destroy(){this.cursor&&this.cursor.remove()}setDropPos(n){this.view.state.field(Hi)!=n&&this.view.dispatch({effects:Vc.of(n)})}},{eventObservers:{dragover(n){this.setDropPos(this.view.posAtCoords({x:n.clientX,y:n.clientY}))},dragleave(n){(n.target==this.view.contentDOM||!this.view.contentDOM.contains(n.relatedTarget))&&this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function zc(){return[Hi,Am]}function Ih(n,e,t,i,s){e.lastIndex=0;for(let r=n.iterRange(t,i),o=t,l;!r.next().done;o+=r.value.length)if(!r.lineBreak)for(;l=e.exec(r.value);)s(o+l.index,l)}function Mm(n,e){let t=n.visibleRanges;if(t.length==1&&t[0].from==n.viewport.from&&t[0].to==n.viewport.to)return t;let i=[];for(let{from:s,to:r}of t)s=Math.max(n.state.doc.lineAt(s).from,s-e),r=Math.min(n.state.doc.lineAt(r).to,r+e),i.length&&i[i.length-1].to>=s?i[i.length-1].to=r:i.push({from:s,to:r});return i}var Ro=class{constructor(e){let{regexp:t,decoration:i,decorate:s,boundary:r,maxLength:o=1e3}=e;if(!t.global)throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set");if(this.regexp=t,s)this.addMatch=(l,a,h,c)=>s(c,h,h+l[0].length,l,a);else if(typeof i=="function")this.addMatch=(l,a,h,c)=>{let f=i(l,a,h);f&&c(h,h+l[0].length,f)};else if(i)this.addMatch=(l,a,h,c)=>c(h,h+l[0].length,i);else throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator");this.boundary=r,this.maxLength=o}createDeco(e){let t=new Se,i=t.add.bind(t);for(let{from:s,to:r}of Mm(e,this.maxLength))Ih(e.state.doc,this.regexp,s,r,(o,l)=>this.addMatch(l,e,o,i));return t.finish()}updateDeco(e,t){let i=1e9,s=-1;return e.docChanged&&e.changes.iterChanges((r,o,l,a)=>{a>=e.view.viewport.from&&l<=e.view.viewport.to&&(i=Math.min(l,i),s=Math.max(a,s))}),e.viewportMoved||s-i>1e3?this.createDeco(e.view):s>-1?this.updateRange(e.view,t.map(e.changes),i,s):t}updateRange(e,t,i,s){for(let r of e.visibleRanges){let o=Math.max(r.from,i),l=Math.min(r.to,s);if(l>=o){let a=e.state.doc.lineAt(o),h=a.toa.from;o--)if(this.boundary.test(a.text[o-1-a.from])){c=o;break}for(;lu.push(y.range(m,g));if(a==h)for(this.regexp.lastIndex=c-a.from;(d=this.regexp.exec(a.text))&&d.indexthis.addMatch(g,e,m,p));t=t.update({filterFrom:c,filterTo:f,filter:(m,g)=>mf,add:u})}}return t}},Po=/x/.unicode!=null?"gu":"g",Tm=new RegExp(`[\0-\b +-\x7F-\x9F\xAD\u061C\u200B\u200E\u200F\u2028\u2029\u202D\u202E\u2066\u2067\u2069\uFEFF\uFFF9-\uFFFC]`,Po),Dm={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"},qr=null;function Om(){var n;if(qr==null&&typeof document<"u"&&document.body){let e=document.body.style;qr=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return qr||!1}var Zn=C.define({combine(n){let e=ce(n,{render:null,specialChars:Tm,addSpecialChars:null});return(e.replaceTabs=!Om())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,Po)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,Po)),e}});function Qo(n={}){return[Zn.of(n),Bm()]}var Nh=null;function Bm(){return Nh||(Nh=Y.fromClass(class{constructor(n){this.view=n,this.decorations=B.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Zn)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new Ro({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:s}=t.state,r=he(e[0],0);if(r==9){let o=s.lineAt(i),l=t.state.tabSize,a=ot(o.text,l,i-o.from);return B.replace({widget:new No((l-a%l)*this.view.defaultCharacterWidth/this.view.scaleX)})}return this.decorationCache[r]||(this.decorationCache[r]=B.replace({widget:new Io(n,r)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Zn);n.startState.facet(Zn)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}var Lm="\u2022";function Em(n){return n>=32?Lm:n==10?"\u2424":String.fromCharCode(9216+n)}var Io=class extends xe{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=Em(this.code),i=e.state.phrase("Control character")+" "+(Dm[this.code]||"0x"+this.code.toString(16)),s=this.options.render&&this.options.render(this.code,i,t);if(s)return s;let r=document.createElement("span");return r.textContent=t,r.title=i,r.setAttribute("aria-label",i),r.className="cm-specialChar",r}ignoreEvent(){return!1}},No=class extends xe{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}};function qc(){return Pm}var Rm=B.line({class:"cm-activeLine"}),Pm=Y.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.docChanged||n.selectionSet)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=-1,t=[];for(let i of n.state.selection.ranges){let s=n.lineBlockAt(i.head);s.from>e&&(t.push(Rm.range(s.from)),e=s.from)}return B.set(t)}},{decorations:n=>n.decorations});var Fo=2e3;function Im(n,e,t){let i=Math.min(e.line,t.line),s=Math.max(e.line,t.line),r=[];if(e.off>Fo||t.off>Fo||e.col<0||t.col<0){let o=Math.min(e.off,t.off),l=Math.max(e.off,t.off);for(let a=i;a<=s;a++){let h=n.doc.line(a);h.length<=l&&r.push(b.range(h.from+o,h.to+l))}}else{let o=Math.min(e.col,t.col),l=Math.max(e.col,t.col);for(let a=i;a<=s;a++){let h=n.doc.line(a),c=Wn(h.text,o,n.tabSize,!0);if(c<0)r.push(b.cursor(h.to));else{let f=Wn(h.text,l,n.tabSize);r.push(b.range(h.from+c,h.from+f))}}}return r}function Nm(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function Fh(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),s=t-i.from,r=s>Fo?-1:s==i.length?Nm(n,e.clientX):ot(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:r,off:s}}function Fm(n,e){let t=Fh(n,e),i=n.state.selection;return t?{update(s){if(s.docChanged){let r=s.changes.mapPos(s.startState.doc.line(t.line).from),o=s.state.doc.lineAt(r);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(s.changes)}},get(s,r,o){let l=Fh(n,s);if(!l)return i;let a=Im(n.state,t,l);return a.length?o?b.create(a.concat(i.ranges)):b.create(a):i}}:null}function Kc(n){let e=n?.eventFilter||(t=>t.altKey&&t.button==0);return A.mouseSelectionStyle.of((t,i)=>e(i)?Fm(t,i):null)}var Hm={Alt:[18,n=>!!n.altKey],Control:[17,n=>!!n.ctrlKey],Shift:[16,n=>!!n.shiftKey],Meta:[91,n=>!!n.metaKey]},Wm={style:"cursor: crosshair"};function $c(n={}){let[e,t]=Hm[n.key||"Alt"],i=Y.fromClass(class{constructor(s){this.view=s,this.isDown=!1}set(s){this.isDown!=s&&(this.isDown=s,this.view.update([]))}},{eventObservers:{keydown(s){this.set(s.keyCode==e||t(s))},keyup(s){(s.keyCode==e||!t(s))&&this.set(!1)},mousemove(s){this.set(t(s))}}});return[i,A.contentAttributes.of(s=>{var r;return!((r=s.plugin(i))===null||r===void 0)&&r.isDown?Wm:null})]}var _n="-10000px",ps=class{constructor(e,t,i,s){this.facet=t,this.createTooltipView=i,this.removeTooltipView=s,this.input=e.state.facet(t),this.tooltips=this.input.filter(o=>o);let r=null;this.tooltipViews=this.tooltips.map(o=>r=i(o,r))}update(e,t){var i;let s=e.state.facet(this.facet),r=s.filter(a=>a);if(s===this.input){for(let a of this.tooltipViews)a.update&&a.update(e);return!1}let o=[],l=t?[]:null;for(let a=0;at[h]=a),t.length=l.length),this.input=s,this.tooltips=r,this.tooltipViews=o,!0}};function Vm(n){let e=n.dom.ownerDocument.documentElement;return{top:0,left:0,bottom:e.clientHeight,right:e.clientWidth}}var Kr=C.define({combine:n=>{var e,t,i;return{position:D.ios?"absolute":((e=n.find(s=>s.position))===null||e===void 0?void 0:e.position)||"fixed",parent:((t=n.find(s=>s.parent))===null||t===void 0?void 0:t.parent)||null,tooltipSpace:((i=n.find(s=>s.tooltipSpace))===null||i===void 0?void 0:i.tooltipSpace)||Vm}}}),Hh=new WeakMap,Zo=Y.fromClass(class{constructor(n){this.view=n,this.above=[],this.inView=!0,this.madeAbsolute=!1,this.lastTransaction=0,this.measureTimeout=-1;let e=n.state.facet(Kr);this.position=e.position,this.parent=e.parent,this.classes=n.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.resizeObserver=typeof ResizeObserver=="function"?new ResizeObserver(()=>this.measureSoon()):null,this.manager=new ps(n,wi,(t,i)=>this.createTooltip(t,i),t=>{this.resizeObserver&&this.resizeObserver.unobserve(t.dom),t.dom.remove()}),this.above=this.manager.tooltips.map(t=>!!t.above),this.intersectionObserver=typeof IntersectionObserver=="function"?new IntersectionObserver(t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()},{threshold:[1]}):null,this.observeIntersection(),n.win.addEventListener("resize",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement("div"),this.container.style.position="relative",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let n of this.manager.tooltipViews)this.intersectionObserver.observe(n.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout(()=>{this.measureTimeout=-1,this.maybeMeasure()},50))}update(n){n.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(n,this.above);e&&this.observeIntersection();let t=e||n.geometryChanged,i=n.state.facet(Kr);if(i.position!=this.position&&!this.madeAbsolute){this.position=i.position;for(let s of this.manager.tooltipViews)s.dom.style.position=this.position;t=!0}if(i.parent!=this.parent){this.parent&&this.container.remove(),this.parent=i.parent,this.createContainer();for(let s of this.manager.tooltipViews)this.container.appendChild(s.dom);t=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);t&&this.maybeMeasure()}createTooltip(n,e){let t=n.create(this.view),i=e?e.dom:null;if(t.dom.classList.add("cm-tooltip"),n.arrow&&!t.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")){let s=document.createElement("div");s.className="cm-tooltip-arrow",t.dom.appendChild(s)}return t.dom.style.position=this.position,t.dom.style.top=_n,t.dom.style.left="0px",this.container.insertBefore(t.dom,i),t.mount&&t.mount(this.view),this.resizeObserver&&this.resizeObserver.observe(t.dom),t}destroy(){var n,e,t;this.view.win.removeEventListener("resize",this.measureSoon);for(let i of this.manager.tooltipViews)i.dom.remove(),(n=i.destroy)===null||n===void 0||n.call(i);this.parent&&this.container.remove(),(e=this.resizeObserver)===null||e===void 0||e.disconnect(),(t=this.intersectionObserver)===null||t===void 0||t.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let n=1,e=1,t=!1;if(this.position=="fixed"&&this.manager.tooltipViews.length){let{dom:r}=this.manager.tooltipViews[0];if(D.safari){let o=r.getBoundingClientRect();t=Math.abs(o.top+1e4)>1||Math.abs(o.left)>1}else t=!!r.offsetParent&&r.offsetParent!=this.container.ownerDocument.body}if(t||this.position=="absolute")if(this.parent){let r=this.parent.getBoundingClientRect();r.width&&r.height&&(n=r.width/this.parent.offsetWidth,e=r.height/this.parent.offsetHeight)}else({scaleX:n,scaleY:e}=this.view.viewState);let i=this.view.scrollDOM.getBoundingClientRect(),s=Yo(this.view);return{visible:{left:i.left+s.left,top:i.top+s.top,right:i.right-s.right,bottom:i.bottom-s.bottom},parent:this.parent?this.container.getBoundingClientRect():this.view.dom.getBoundingClientRect(),pos:this.manager.tooltips.map((r,o)=>{let l=this.manager.tooltipViews[o];return l.getCoords?l.getCoords(r.pos):this.view.coordsAtPos(r.pos)}),size:this.manager.tooltipViews.map(({dom:r})=>r.getBoundingClientRect()),space:this.view.state.facet(Kr).tooltipSpace(this.view),scaleX:n,scaleY:e,makeAbsolute:t}}writeMeasure(n){var e;if(n.makeAbsolute){this.madeAbsolute=!0,this.position="absolute";for(let l of this.manager.tooltipViews)l.dom.style.position="absolute"}let{visible:t,space:i,scaleX:s,scaleY:r}=n,o=[];for(let l=0;l=Math.min(t.bottom,i.bottom)||f.rightMath.min(t.right,i.right)+.1)){c.style.top=_n;continue}let d=a.arrow?h.dom.querySelector(".cm-tooltip-arrow"):null,p=d?7:0,m=u.right-u.left,g=(e=Hh.get(h))!==null&&e!==void 0?e:u.bottom-u.top,y=h.offset||qm,x=this.view.textDirection==V.LTR,w=u.width>i.right-i.left?x?i.left:i.right-u.width:x?Math.max(i.left,Math.min(f.left-(d?14:0)+y.x,i.right-m)):Math.min(Math.max(i.left,f.left-m+(d?14:0)-y.x),i.right-m),O=this.above[l];!a.strictSide&&(O?f.top-g-p-y.yi.bottom)&&O==i.bottom-f.bottom>f.top-i.top&&(O=this.above[l]=!O);let v=(O?f.top-i.top:i.bottom-f.bottom)-p;if(vw&&R.topk&&(k=O?R.top-g-2-p:R.bottom+p+2);if(this.position=="absolute"?(c.style.top=(k-n.parent.top)/r+"px",Wh(c,(w-n.parent.left)/s)):(c.style.top=k/r+"px",Wh(c,w/s)),d){let R=f.left+(x?y.x:-y.x)-(w+14-7);d.style.left=R/s+"px"}h.overlap!==!0&&o.push({left:w,top:k,right:S,bottom:k+g}),c.classList.toggle("cm-tooltip-above",O),c.classList.toggle("cm-tooltip-below",!O),h.positioned&&h.positioned(n.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let n of this.manager.tooltipViews)n.dom.style.top=_n}},{eventObservers:{scroll(){this.maybeMeasure()}}});function Wh(n,e){let t=parseInt(n.style.left,10);(isNaN(t)||Math.abs(e-t)>1)&&(n.style.left=e+"px")}var zm=A.baseTheme({".cm-tooltip":{zIndex:500,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:"14px",position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),qm={x:0,y:0},wi=C.define({enables:[Zo,zm]}),ms=C.define({combine:n=>n.reduce((e,t)=>e.concat(t),[])}),gs=class n{static create(e){return new n(e)}constructor(e){this.view=e,this.mounted=!1,this.dom=document.createElement("div"),this.dom.classList.add("cm-tooltip-hover"),this.manager=new ps(e,ms,(t,i)=>this.createHostedView(t,i),t=>t.dom.remove())}createHostedView(e,t){let i=e.create(this.view);return i.dom.classList.add("cm-tooltip-section"),this.dom.insertBefore(i.dom,t?t.dom.nextSibling:this.dom.firstChild),this.mounted&&i.mount&&i.mount(this.view),i}mount(e){for(let t of this.manager.tooltipViews)t.mount&&t.mount(e);this.mounted=!0}positioned(e){for(let t of this.manager.tooltipViews)t.positioned&&t.positioned(e)}update(e){this.manager.update(e)}destroy(){var e;for(let t of this.manager.tooltipViews)(e=t.destroy)===null||e===void 0||e.call(t)}passProp(e){let t;for(let i of this.manager.tooltipViews){let s=i[e];if(s!==void 0){if(t===void 0)t=s;else if(t!==s)return}}return t}get offset(){return this.passProp("offset")}get getCoords(){return this.passProp("getCoords")}get overlap(){return this.passProp("overlap")}get resize(){return this.passProp("resize")}},Km=wi.compute([ms],n=>{let e=n.facet(ms);return e.length===0?null:{pos:Math.min(...e.map(t=>t.pos)),end:Math.max(...e.map(t=>{var i;return(i=t.end)!==null&&i!==void 0?i:t.pos})),create:gs.create,above:e[0].above,arrow:e.some(t=>t.arrow)}}),jc=C.define(),Ho=class{constructor(e,t,i,s,r,o){this.view=e,this.source=t,this.field=i,this.locked=s,this.setHover=r,this.hoverTime=o,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:e.dom,time:0},this.checkHover=this.checkHover.bind(this),e.dom.addEventListener("mouseleave",this.mouseleave=this.mouseleave.bind(this)),e.dom.addEventListener("mousemove",this.mousemove=this.mousemove.bind(this))}update(e){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout(()=>this.startHover(),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active.length)return;let e=Date.now()-this.lastMove.time;eo.bottom||t.xo.right+e.defaultCharacterWidth)return;let l=e.bidiSpans(e.state.doc.lineAt(s)).find(h=>h.from<=s&&h.to>=s),a=l&&l.dir==V.RTL?-1:1;r=t.x{if(l&&!(Array.isArray(l)&&!l.length)){let a=Array.isArray(l)?l:[l];s&&this.locked.set(a,s),e.dispatch({effects:this.setHover.of(a)})}};if(r&&"then"in r){let l=this.pending={pos:t};r.then(a=>{this.pending==l&&(this.pending=null,o(a))},a=>re(e.state,a,"hover tooltip"))}else o(r)}get tooltip(){let e=this.view.plugin(Zo),t=e?e.manager.tooltips.findIndex(i=>i.create==gs.create):-1;return t>-1?e.manager.tooltipViews[t]:null}mousemove(e){var t,i;this.lastMove={x:e.clientX,y:e.clientY,target:e.target,time:Date.now()},this.hoverTimeout<0&&(this.hoverTimeout=setTimeout(this.checkHover,this.hoverTime));let{active:s,tooltip:r}=this;if(s.length&&!this.locked.has(s)&&r&&!$m(r.dom,e)||this.pending){let{pos:o}=s[0]||this.pending,l=(i=(t=s[0])===null||t===void 0?void 0:t.end)!==null&&i!==void 0?i:o;(o==l?this.view.posAtCoords(this.lastMove)!=o:!jm(this.view,o,l,e.clientX,e.clientY))&&(this.view.dispatch({effects:this.setHover.of([])}),this.pending=null)}}mouseleave(e){clearTimeout(this.hoverTimeout),this.hoverTimeout=-1;let{active:t}=this;if(t.length&&!this.locked.has(t)){let{tooltip:i}=this;i&&i.dom.contains(e.relatedTarget)?this.watchTooltipLeave(i.dom):this.view.dispatch({effects:this.setHover.of([])})}}watchTooltipLeave(e){let t=i=>{e.removeEventListener("mouseleave",t);let{active:s}=this;s.length&&!this.locked.has(s)&&!this.view.dom.contains(i.relatedTarget)&&this.view.dispatch({effects:this.setHover.of([])})};e.addEventListener("mouseleave",t)}destroy(){clearTimeout(this.hoverTimeout),clearTimeout(this.restartTimeout),this.view.dom.removeEventListener("mouseleave",this.mouseleave),this.view.dom.removeEventListener("mousemove",this.mousemove)}},Yn=4;function $m(n,e){let{left:t,right:i,top:s,bottom:r}=n.getBoundingClientRect(),o;if(o=n.querySelector(".cm-tooltip-arrow")){let l=o.getBoundingClientRect();s=Math.min(l.top,s),r=Math.max(l.bottom,r)}return e.clientX>=t-Yn&&e.clientX<=i+Yn&&e.clientY>=s-Yn&&e.clientY<=r+Yn}function jm(n,e,t,i,s,r){let o=n.scrollDOM.getBoundingClientRect(),l=n.documentTop+n.documentPadding.top+n.contentHeight;if(o.left>i||o.rights||Math.min(o.bottom,l)=e&&a<=t}function Uc(n,e={}){let t=L.define(),i=new WeakMap,s=J.define({create(){return[]},update(o,l){let a=i.get(o);if(o.length&&(e.hideOnChange&&(l.docChanged||l.selection)?o=[]:a&&a(l)?o=[]:e.hideOn&&(o=o.filter(h=>!e.hideOn(l,h)))),l.docChanged&&o.length){let h=[];for(let c of o){let f=l.changes.mapPos(c.pos,-1,se.TrackDel);if(f!=null){let u=Object.assign(Object.create(null),c);u.pos=f,u.end!=null&&(u.end=l.changes.mapPos(u.end)),h.push(u)}}o=h}for(let h of l.effects)h.is(t)&&(o=h.value,a=void 0),(h.is(Um)&&!h.value||h.value==s)&&(o=[]);return o.length&&a&&i.set(o,a),o},provide:o=>ms.from(o)}),r=Y.define(o=>new Ho(o,n,s,i,t,e.hoverTime||300));return{active:s,extension:[s,r,jc.of(r),Km]}}function el(n,e,t,i={}){var s;let r=n.state.facet(jc).map(o=>n.plugin(o)).filter(o=>!!o);if(i.tooltip&&i.tooltip.active){let o=r.find(l=>l.field==i.tooltip.active);o&&(r=[o])}for(let o of r)o.activateHover(n,e,t,(s=i.until)!==null&&s!==void 0?s:(()=>!1))}function tl(n,e){let t=n.plugin(Zo);if(!t)return null;let i=t.manager.tooltips.indexOf(e);return i<0?null:t.manager.tooltipViews[i]}var Um=L.define();var Vh=C.define({combine(n){let e,t;for(let i of n)e=e||i.topContainer,t=t||i.bottomContainer;return{topContainer:e,bottomContainer:t}}});function Zi(n,e){let t=n.plugin(Gc),i=t?t.specs.indexOf(e):-1;return i>-1?t.panels[i]:null}var Gc=Y.fromClass(class{constructor(n){this.input=n.state.facet(qt),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(n));let e=n.state.facet(Vh);this.top=new ci(n,!0,e.topContainer),this.bottom=new ci(n,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(n){let e=n.state.facet(Vh);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new ci(n.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new ci(n.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=n.state.facet(qt);if(t!=this.input){let i=t.filter(a=>a),s=[],r=[],o=[],l=[];for(let a of i){let h=this.specs.indexOf(a),c;h<0?(c=a(n.view),l.push(c)):(c=this.panels[h],c.update&&c.update(n)),s.push(c),(c.top?r:o).push(c)}this.specs=i,this.panels=s,this.top.sync(r),this.bottom.sync(o);for(let a of l)a.dom.classList.add("cm-panel"),a.mount&&a.mount()}else for(let i of this.panels)i.update&&i.update(n)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:n=>A.scrollMargins.of(e=>{let t=e.plugin(n);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})}),ci=class{constructor(e,t,i){this.view=e,this.top=t,this.container=i,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom",this.dom.style[this.top?"top":"bottom"]="0";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=zh(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=zh(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}};function zh(n){let e=n.nextSibling;return n.remove(),e}var qt=C.define({enables:Gc});function _c(n,e){let t,i=new Promise(o=>t=o),s=o=>Gm(o,e,t);n.state.field($r,!1)?n.dispatch({effects:Yc.of(s)}):n.dispatch({effects:L.appendConfig.of($r.init(()=>[s]))});let r=Jc.of(s);return{close:r,result:i.then(o=>((n.win.queueMicrotask||(a=>n.win.setTimeout(a,10)))(()=>{n.state.field($r).indexOf(s)>-1&&n.dispatch({effects:r})}),o))}}var $r=J.define({create(){return[]},update(n,e){for(let t of e.effects)t.is(Yc)?n=[t.value].concat(n):t.is(Jc)&&(n=n.filter(i=>i!=t.value));return n},provide:n=>qt.computeN([n],e=>e.field(n))}),Yc=L.define(),Jc=L.define();function Gm(n,e,t){let i=e.content?e.content(n,()=>o(null)):null;if(!i){if(i=W("form"),e.input){let l=W("input",e.input);/^(text|password|number|email|tel|url)$/.test(l.type)&&l.classList.add("cm-textfield"),l.name||(l.name="input"),i.appendChild(W("label",(e.label||"")+": ",l))}else i.appendChild(document.createTextNode(e.label||""));i.appendChild(document.createTextNode(" ")),i.appendChild(W("button",{class:"cm-button",type:"submit"},e.submitLabel||"OK"))}let s=i.nodeName=="FORM"?[i]:i.querySelectorAll("form");for(let l=0;l{h.keyCode==27?(h.preventDefault(),o(null)):h.keyCode==13&&(h.preventDefault(),o(a))}),a.addEventListener("submit",h=>{h.preventDefault(),o(a)})}let r=W("div",i,W("button",{onclick:()=>o(null),"aria-label":n.state.phrase("close"),class:"cm-dialog-close",type:"button"},["\xD7"]));e.class&&(r.className=e.class),r.classList.add("cm-dialog");function o(l){r.contains(r.ownerDocument.activeElement)&&n.focus(),t(l)}return{dom:r,top:e.top,mount:()=>{if(e.focus){let l;typeof e.focus=="string"?l=i.querySelector(e.focus):l=i.querySelector("input")||i.querySelector("button"),l&&"select"in l?l.select():l&&"focus"in l&&l.focus()}}}}var Ae=class extends Pe{compare(e){return this==e||this.constructor==e.constructor&&this.eq(e)}eq(e){return!1}destroy(e){}};Ae.prototype.elementClass="";Ae.prototype.toDOM=void 0;Ae.prototype.mapMode=se.TrackBefore;Ae.prototype.startSide=Ae.prototype.endSide=-1;Ae.prototype.point=!0;var es=C.define(),_m=C.define(),Ym={class:"",renderEmptyElements:!1,elementStyle:"",markers:()=>I.empty,lineMarker:()=>null,widgetMarker:()=>null,lineMarkerChange:null,initialSpacer:null,updateSpacer:null,domEventHandlers:{},side:"before"},Ui=C.define();function ks(n){return[Xc(),Ui.of({...Ym,...n})]}var Wo=C.define({combine:n=>n.some(e=>e)});function Xc(n){let e=[Jm];return n&&n.fixed===!1&&e.push(Wo.of(!0)),e}var Jm=Y.fromClass(class{constructor(n){this.view=n,this.domAfter=null,this.prevViewport=n.viewport,this.dom=document.createElement("div"),this.dom.className="cm-gutters cm-gutters-before",this.dom.setAttribute("aria-hidden","true"),this.dom.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.gutters=n.state.facet(Ui).map(e=>new ys(n,e)),this.fixed=!n.state.facet(Wo);for(let e of this.gutters)e.config.side=="after"?this.getDOMAfter().appendChild(e.dom):this.dom.appendChild(e.dom);this.fixed&&(this.dom.style.position="sticky"),this.syncGutters(!1),n.scrollDOM.insertBefore(this.dom,n.contentDOM)}getDOMAfter(){return this.domAfter||(this.domAfter=document.createElement("div"),this.domAfter.className="cm-gutters cm-gutters-after",this.domAfter.setAttribute("aria-hidden","true"),this.domAfter.style.minHeight=this.view.contentHeight/this.view.scaleY+"px",this.domAfter.style.position=this.fixed?"sticky":"",this.view.scrollDOM.appendChild(this.domAfter)),this.domAfter}update(n){if(this.updateGutters(n)){let e=this.prevViewport,t=n.view.viewport,i=Math.min(e.to,t.to)-Math.max(e.from,t.from);this.syncGutters(i<(t.to-t.from)*.8)}if(n.geometryChanged){let e=this.view.contentHeight/this.view.scaleY+"px";this.dom.style.minHeight=e,this.domAfter&&(this.domAfter.style.minHeight=e)}this.view.state.facet(Wo)!=!this.fixed&&(this.fixed=!this.fixed,this.dom.style.position=this.fixed?"sticky":"",this.domAfter&&(this.domAfter.style.position=this.fixed?"sticky":"")),this.prevViewport=n.view.viewport}syncGutters(n){let e=this.dom.nextSibling;n&&(this.dom.remove(),this.domAfter&&this.domAfter.remove());let t=I.iter(this.view.state.facet(es),this.view.viewport.from),i=[],s=this.gutters.map(r=>new zo(r,this.view.viewport,-this.view.documentPadding.top));for(let r of this.view.viewportLineBlocks)if(i.length&&(i=[]),Array.isArray(r.type)){let o=!0;for(let l of r.type)if(l.type==fe.Text&&o){Vo(t,i,l.from);for(let a of s)a.line(this.view,l,i);o=!1}else if(l.widget)for(let a of s)a.widget(this.view,l)}else if(r.type==fe.Text){Vo(t,i,r.from);for(let o of s)o.line(this.view,r,i)}else if(r.widget)for(let o of s)o.widget(this.view,r);for(let r of s)r.finish();n&&(this.view.scrollDOM.insertBefore(this.dom,e),this.domAfter&&this.view.scrollDOM.appendChild(this.domAfter))}updateGutters(n){let e=n.startState.facet(Ui),t=n.state.facet(Ui),i=n.docChanged||n.heightChanged||n.viewportChanged||!I.eq(n.startState.facet(es),n.state.facet(es),n.view.viewport.from,n.view.viewport.to);if(e==t)for(let s of this.gutters)s.update(n)&&(i=!0);else{i=!0;let s=[];for(let r of t){let o=e.indexOf(r);o<0?s.push(new ys(this.view,r)):(this.gutters[o].update(n),s.push(this.gutters[o]))}for(let r of this.gutters)r.dom.remove(),s.indexOf(r)<0&&r.destroy();for(let r of s)r.config.side=="after"?this.getDOMAfter().appendChild(r.dom):this.dom.appendChild(r.dom);this.gutters=s}return i}destroy(){for(let n of this.gutters)n.destroy();this.dom.remove(),this.domAfter&&this.domAfter.remove()}},{provide:n=>A.scrollMargins.of(e=>{let t=e.plugin(n);if(!t||t.gutters.length==0||!t.fixed)return null;let i=t.dom.offsetWidth*e.scaleX,s=t.domAfter?t.domAfter.offsetWidth*e.scaleX:0;return e.textDirection==V.LTR?{left:i,right:s}:{right:i,left:s}})});function qh(n){return Array.isArray(n)?n:[n]}function Vo(n,e,t){for(;n.value&&n.from<=t;)n.from==t&&e.push(n.value),n.next()}var zo=class{constructor(e,t,i){this.gutter=e,this.height=i,this.i=0,this.cursor=I.iter(e.markers,t.from)}addElement(e,t,i){let{gutter:s}=this,r=(t.top-this.height)/e.scaleY,o=t.height/e.scaleY;if(this.i==s.elements.length){let l=new bs(e,o,r,i);s.elements.push(l),s.dom.appendChild(l.dom)}else s.elements[this.i].update(e,o,r,i);this.height=t.bottom,this.i++}line(e,t,i){let s=[];Vo(this.cursor,s,t.from),i.length&&(s=s.concat(i));let r=this.gutter.config.lineMarker(e,t,s);r&&s.unshift(r);let o=this.gutter;s.length==0&&!o.config.renderEmptyElements||this.addElement(e,t,s)}widget(e,t){let i=this.gutter.config.widgetMarker(e,t.widget,t),s=i?[i]:null;for(let r of e.state.facet(_m)){let o=r(e,t.widget,t);o&&(s||(s=[])).push(o)}s&&this.addElement(e,t,s)}finish(){let e=this.gutter;for(;e.elements.length>this.i;){let t=e.elements.pop();e.dom.removeChild(t.dom),t.destroy()}}},ys=class{constructor(e,t){this.view=e,this.config=t,this.elements=[],this.spacer=null,this.dom=document.createElement("div"),this.dom.className="cm-gutter"+(this.config.class?" "+this.config.class:"");for(let i in t.domEventHandlers)this.dom.addEventListener(i,s=>{let r=s.target,o;if(r!=this.dom&&this.dom.contains(r)){for(;r.parentNode!=this.dom;)r=r.parentNode;let a=r.getBoundingClientRect();o=(a.top+a.bottom)/2}else o=s.clientY;let l=e.lineBlockAtHeight(o-e.documentTop);t.domEventHandlers[i](e,l,s)&&s.preventDefault()});this.markers=qh(t.markers(e)),t.initialSpacer&&(this.spacer=new bs(e,0,0,[t.initialSpacer(e)]),this.dom.appendChild(this.spacer.dom),this.spacer.dom.style.cssText+="visibility: hidden; pointer-events: none")}update(e){let t=this.markers;if(this.markers=qh(this.config.markers(e.view)),this.spacer&&this.config.updateSpacer){let s=this.config.updateSpacer(this.spacer.markers[0],e);s!=this.spacer.markers[0]&&this.spacer.update(e.view,0,0,[s])}let i=e.view.viewport;return!I.eq(this.markers,t,i.from,i.to)||(this.config.lineMarkerChange?this.config.lineMarkerChange(e):!1)}destroy(){for(let e of this.elements)e.destroy()}},bs=class{constructor(e,t,i,s){this.height=-1,this.above=0,this.markers=[],this.dom=document.createElement("div"),this.dom.className="cm-gutterElement",this.update(e,t,i,s)}update(e,t,i,s){this.height!=t&&(this.height=t,this.dom.style.height=t+"px"),this.above!=i&&(this.dom.style.marginTop=(this.above=i)?i+"px":""),Xm(this.markers,s)||this.setMarkers(e,s)}setMarkers(e,t){let i="cm-gutterElement",s=this.dom.firstChild;for(let r=0,o=0;;){let l=o,a=rr(l,a,h)||o(l,a,h):o}return i}})}}),Gi=class extends Ae{constructor(e){super(),this.number=e}eq(e){return this.number==e.number}toDOM(){return document.createTextNode(this.number)}};function jr(n,e){return n.state.facet(fi).formatNumber(e,n.state)}var eg=Ui.compute([fi],n=>({class:"cm-lineNumbers",renderEmptyElements:!1,markers(e){return e.state.facet(Qm)},lineMarker(e,t,i){return i.some(s=>s.toDOM)?null:new Gi(jr(e,e.state.doc.lineAt(t.from).number))},widgetMarker:(e,t,i)=>{for(let s of e.state.facet(Zm)){let r=s(e,t,i);if(r)return r}return null},lineMarkerChange:e=>e.startState.facet(fi)!=e.state.facet(fi),initialSpacer(e){return new Gi(jr(e,Kh(e.state.doc.lines)))},updateSpacer(e,t){let i=jr(t.view,Kh(t.view.state.doc.lines));return i==e.number?e:new Gi(i)},domEventHandlers:n.facet(fi).domEventHandlers,side:"before"}));function Qc(n={}){return[fi.of(n),Xc(),eg]}function Kh(n){let e=9;for(;e{let e=[],t=-1;for(let i of n.selection.ranges){let s=n.doc.lineAt(i.head).from;s>t&&(t=s,e.push(tg.range(s)))}return I.of(e)});function Zc(){return ig}var Gf={};Tn(Gf,{DocInput:()=>Es,HighlightStyle:()=>fn,IndentContext:()=>dt,LRLanguage:()=>gl,Language:()=>ue,LanguageDescription:()=>bl,LanguageSupport:()=>yl,ParseContext:()=>vi,StreamLanguage:()=>Cl,StringStream:()=>un,TreeIndentContext:()=>Rs,bidiIsolates:()=>e0,bracketMatching:()=>Nl,bracketMatchingHandle:()=>Vf,codeFolding:()=>Pl,continuedIndent:()=>Ag,defaultHighlightStyle:()=>Fs,defineLanguageFacet:()=>Tl,delimitedIndent:()=>Sg,ensureSyntaxTree:()=>kf,flatIndent:()=>Cg,foldAll:()=>Rf,foldCode:()=>Lf,foldEffect:()=>Ci,foldGutter:()=>Il,foldInside:()=>Tg,foldKeymap:()=>El,foldNodeProp:()=>Of,foldService:()=>Df,foldState:()=>pt,foldable:()=>ki,foldedRanges:()=>Bg,forceParsing:()=>pg,getIndentUnit:()=>ut,getIndentation:()=>Si,highlightingFor:()=>Fg,indentNodeProp:()=>Dl,indentOnInput:()=>Ol,indentRange:()=>gg,indentService:()=>Af,indentString:()=>At,indentUnit:()=>_t,language:()=>ft,languageDataProp:()=>Ct,matchBrackets:()=>We,sublanguageProp:()=>vf,syntaxHighlighting:()=>Ns,syntaxParserRunning:()=>mg,syntaxTree:()=>Z,syntaxTreeAvailable:()=>dg,toggleFold:()=>Rg,unfoldAll:()=>Pf,unfoldCode:()=>Ef,unfoldEffect:()=>Yt});var ng=0,en=class{constructor(e,t){this.from=e,this.to=t}},P=class{constructor(e={}){this.id=ng++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")}),this.combine=e.combine||null}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=ve.match(e)),t=>{let i=e(t);return i===void 0?null:[this,i]}}};P.closedBy=new P({deserialize:n=>n.split(" ")});P.openedBy=new P({deserialize:n=>n.split(" ")});P.group=new P({deserialize:n=>n.split(" ")});P.isolate=new P({deserialize:n=>{if(n&&n!="rtl"&&n!="ltr"&&n!="auto")throw new RangeError("Invalid value for isolate: "+n);return n||"auto"}});P.contextHash=new P({perNode:!0});P.lookAhead=new P({perNode:!0});P.mounted=new P({perNode:!0});var $t=class{constructor(e,t,i,s=!1){this.tree=e,this.overlay=t,this.parser=i,this.bracketed=s}static get(e){return e&&e.props&&e.props[P.mounted.id]}},sg=Object.create(null),ve=class n{constructor(e,t,i,s=0){this.name=e,this.props=t,this.id=i,this.flags=s}static define(e){let t=e.props&&e.props.length?Object.create(null):sg,i=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),s=new n(e.name||"",t,e.id,i);if(e.props){for(let r of e.props)if(Array.isArray(r)||(r=r(s)),r){if(r[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[r[0].id]=r[1]}}return s}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(P.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let i in e)for(let s of i.split(" "))t[s]=e[i];return i=>{for(let s=i.prop(P.group),r=-1;r<(s?s.length:0);r++){let o=t[r<0?i.name:s[r]];if(o)return o}}}};ve.none=new ve("",Object.create(null),0,8);var As=class n{constructor(e){this.types=e;for(let t=0;t0;for(let a=this.cursor(o|ee.IncludeAnonymous);;){let h=!1;if(a.from<=r&&a.to>=s&&(!l&&a.type.isAnonymous||t(a)!==!1)){if(a.firstChild())continue;h=!0}for(;h&&i&&(l||!a.type.isAnonymous)&&i(a),!a.nextSibling();){if(!a.parent())return;h=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:al(ve.none,this.children,this.positions,0,this.children.length,0,this.length,(t,i,s)=>new n(this.type,t,i,s,this.propValues),e.makeTree||((t,i,s)=>new n(ve.none,t,i,s)))}static build(e){return og(e)}};Q.empty=new Q(ve.none,[],[],0);var il=class n{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new n(this.buffer,this.index)}},vt=class n{constructor(e,t,i){this.buffer=e,this.length=t,this.set=i}get type(){return ve.none}toString(){let e=[];for(let t=0;t0));a=o[a+3]);return l}slice(e,t,i){let s=this.buffer,r=new Uint16Array(t-e),o=0;for(let l=e,a=0;l=e&&te;case 1:return t<=e&&i>e;case 2:return i>e;case 4:return!0}}function tn(n,e,t,i){for(var s;n.from==n.to||(t<1?n.from>=e:n.from>e)||(t>-1?n.to<=e:n.to0?l.length:-1;e!=h;e+=t){let c=l[e],f=a[e]+o.from,u;if(!(!(r&ee.EnterBracketed&&c instanceof Q&&(u=$t.get(c))&&!u.overlay&&u.bracketed&&i>=f&&i<=f+c.length)&&!sf(s,i,f,f+c.length))){if(c instanceof vt){if(r&ee.ExcludeBuffers)continue;let d=c.findChild(0,c.buffer.length,t,i-f,s);if(d>-1)return new nn(new sl(o,c,e,f),null,d)}else if(r&ee.IncludeAnonymous||!c.type.isAnonymous||ll(c)){let d;if(!(r&ee.IgnoreMounts)&&(d=$t.get(c))&&!d.overlay)return new n(d.tree,f,e,o);let p=new n(c,f,e,o);return r&ee.IncludeAnonymous||!p.type.isAnonymous?p:p.nextChild(t<0?c.children.length-1:0,t,i,s,r)}}}if(r&ee.IncludeAnonymous||!o.type.isAnonymous||(o.index>=0?e=o.index+t:e=t<0?-1:o._parent._tree.children.length,o=o._parent,!o))return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(e){return this.nextChild(0,1,e,2)}childBefore(e){return this.nextChild(this._tree.children.length-1,-1,e,-2)}prop(e){return this._tree.prop(e)}enter(e,t,i=0){let s;if(!(i&ee.IgnoreOverlays)&&(s=$t.get(this._tree))&&s.overlay){let r=e-this.from,o=i&ee.EnterBracketed&&s.bracketed;for(let{from:l,to:a}of s.overlay)if((t>0||o?l<=r:l=r:a>r))return new n(s.tree,s.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,e,t,i)}nextSignificantParent(){let e=this;for(;e.type.isAnonymous&&e._parent;)e=e._parent;return e}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}get tree(){return this._tree}toTree(){return this._tree}toString(){return this._tree.toString()}};function tf(n,e,t,i){let s=n.cursor(),r=[];if(!s.firstChild())return r;if(t!=null){for(let o=!1;!o;)if(o=s.type.is(t),!s.nextSibling())return r}for(;;){if(i!=null&&s.type.is(i))return r;if(s.type.is(e)&&r.push(s.node),!s.nextSibling())return i==null?r:[]}}function nl(n,e,t=e.length-1){for(let i=n;t>=0;i=i.parent){if(!i)return!1;if(!i.type.isAnonymous){if(e[t]&&e[t]!=i.name)return!1;t--}}return!0}var sl=class{constructor(e,t,i,s){this.parent=e,this.buffer=t,this.index=i,this.start=s}},nn=class n extends Ms{get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}constructor(e,t,i){super(),this.context=e,this._parent=t,this.index=i,this.type=e.buffer.set.types[e.buffer.buffer[i]]}child(e,t,i){let{buffer:s}=this.context,r=s.findChild(this.index+4,s.buffer[this.index+3],e,t-this.context.start,i);return r<0?null:new n(this.context,this,r)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(e){return this.child(1,e,2)}childBefore(e){return this.child(-1,e,-2)}prop(e){return this.type.prop(e)}enter(e,t,i=0){if(i&ee.ExcludeBuffers)return null;let{buffer:s}=this.context,r=s.findChild(this.index+4,s.buffer[this.index+3],t>0?1:-1,e-this.context.start,t);return r<0?null:new n(this.context,this,r)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(e){return this._parent?null:this.context.parent.nextChild(this.context.index+e,e,0,4)}get nextSibling(){let{buffer:e}=this.context,t=e.buffer[this.index+3];return t<(this._parent?e.buffer[this._parent.index+3]:e.buffer.length)?new n(this.context,this._parent,t):this.externalSibling(1)}get prevSibling(){let{buffer:e}=this.context,t=this._parent?this._parent.index+4:0;return this.index==t?this.externalSibling(-1):new n(this.context,this._parent,e.findChild(t,this.index,-1,0,4))}get tree(){return null}toTree(){let e=[],t=[],{buffer:i}=this.context,s=this.index+4,r=i.buffer[this.index+3];if(r>s){let o=i.buffer[this.index+1];e.push(i.slice(s,r,o)),t.push(0)}return new Q(this.type,e,t,this.to-this.from)}toString(){return this.context.buffer.childString(this.index)}};function rf(n){if(!n.length)return null;let e=0,t=n[0];for(let r=1;rt.from||o.to=e){let l=new Ze(o.tree,o.overlay[0].from+r.from,-1,r);(s||(s=[i])).push(tn(l,e,t,!1))}}return s?rf(s):i}var sn=class{get name(){return this.type.name}constructor(e,t=0){if(this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,this.mode=t&~ee.EnterBracketed,e instanceof Ze)this.yieldNode(e);else{this._tree=e.context.parent,this.buffer=e.context;for(let i=e._parent;i;i=i._parent)this.stack.unshift(i.index);this.bufferNode=e,this.yieldBuf(e.index)}}yieldNode(e){return e?(this._tree=e,this.type=e.type,this.from=e.from,this.to=e.to,!0):!1}yieldBuf(e,t){this.index=e;let{start:i,buffer:s}=this.buffer;return this.type=t||s.set.types[s.buffer[e]],this.from=i+s.buffer[e+1],this.to=i+s.buffer[e+2],!0}yield(e){return e?e instanceof Ze?(this.buffer=null,this.yieldNode(e)):(this.buffer=e.context,this.yieldBuf(e.index,e.type)):!1}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(e,t,i){if(!this.buffer)return this.yield(this._tree.nextChild(e<0?this._tree._tree.children.length-1:0,e,t,i,this.mode));let{buffer:s}=this.buffer,r=s.findChild(this.index+4,s.buffer[this.index+3],e,t-this.buffer.start,i);return r<0?!1:(this.stack.push(this.index),this.yieldBuf(r))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(e){return this.enterChild(1,e,2)}childBefore(e){return this.enterChild(-1,e,-2)}enter(e,t,i=this.mode){return this.buffer?i&ee.ExcludeBuffers?!1:this.enterChild(1,e,t):this.yield(this._tree.enter(e,t,i))}parent(){if(!this.buffer)return this.yieldNode(this.mode&ee.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let e=this.mode&ee.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(e)}sibling(e){if(!this.buffer)return this._tree._parent?this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+e,e,0,4,this.mode)):!1;let{buffer:t}=this.buffer,i=this.stack.length-1;if(e<0){let s=i<0?0:this.stack[i]+4;if(this.index!=s)return this.yieldBuf(t.findChild(s,this.index,-1,0,4))}else{let s=t.buffer[this.index+3];if(s<(i<0?t.buffer.length:t.buffer[this.stack[i]+3]))return this.yieldBuf(s)}return i<0?this.yield(this.buffer.parent.nextChild(this.buffer.index+e,e,0,4,this.mode)):!1}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(e){let t,i,{buffer:s}=this;if(s){if(e>0){if(this.index-1)for(let r=t+e,o=e<0?-1:i._tree.children.length;r!=o;r+=e){let l=i._tree.children[r];if(this.mode&ee.IncludeAnonymous||l instanceof vt||!l.type.isAnonymous||ll(l))return!1}return!0}move(e,t){if(t&&this.enterChild(e,0,4))return!0;for(;;){if(this.sibling(e))return!0;if(this.atLastNode(e)||!this.parent())return!1}}next(e=!0){return this.move(1,e)}prev(e=!0){return this.move(-1,e)}moveTo(e,t=0){for(;(this.from==this.to||(t<1?this.from>=e:this.from>e)||(t>-1?this.to<=e:this.to=0;){for(let o=e;o;o=o._parent)if(o.index==s){if(s==this.index)return o;t=o,i=r+1;break e}s=this.stack[--r]}for(let s=i;s=0;r--){if(r<0)return nl(this._tree,e,s);let o=i[t.buffer[this.stack[r]]];if(!o.isAnonymous){if(e[s]&&e[s]!=o.name)return!1;s--}}return!0}};function ll(n){return n.children.some(e=>e instanceof vt||!e.type.isAnonymous||ll(e))}function og(n){var e;let{buffer:t,nodeSet:i,maxBufferLength:s=1024,reused:r=[],minRepeatType:o=i.types.length}=n,l=Array.isArray(t)?new il(t,t.length):t,a=i.types,h=0,c=0;function f(v,k,S,R,H,$){let{id:N,start:E,end:q,size:z}=l,G=c,ge=h;if(z<0)if(l.next(),z==-1){let st=r[N];S.push(st),R.push(E-v);return}else if(z==-3){h=N;return}else if(z==-4){c=N;return}else throw new RangeError(`Unrecognized record size: ${z}`);let Me=a[N],Ye,ae,Ee=E-v;if(q-E<=s&&(ae=g(l.pos-k,H))){let st=new Uint16Array(ae.size-ae.skip),Re=l.pos-ae.size,Je=st.length;for(;l.pos>Re;)Je=y(ae.start,st,Je);Ye=new vt(st,q-ae.start,i),Ee=ae.start-v}else{let st=l.pos-z;l.next();let Re=[],Je=[],Bt=N>=o?N:-1,ti=0,Mn=q;for(;l.pos>st;)Bt>=0&&l.id==Bt&&l.size>=0?(l.end<=Mn-s&&(p(Re,Je,E,ti,l.end,Mn,Bt,G,ge),ti=Re.length,Mn=l.end),l.next()):$>2500?u(E,st,Re,Je):f(E,st,Re,Je,Bt,$+1);if(Bt>=0&&ti>0&&ti-1&&ti>0){let La=d(Me,ge);Ye=al(Me,Re,Je,0,Re.length,0,q-E,La,La)}else Ye=m(Me,Re,Je,q-E,G-q,ge)}S.push(Ye),R.push(Ee)}function u(v,k,S,R){let H=[],$=0,N=-1;for(;l.pos>k;){let{id:E,start:q,end:z,size:G}=l;if(G>4)l.next();else{if(N>-1&&q=0;z-=3)E[G++]=H[z],E[G++]=H[z+1]-q,E[G++]=H[z+2]-q,E[G++]=G;S.push(new vt(E,H[2]-q,i)),R.push(q-v)}}function d(v,k){return(S,R,H)=>{let $=0,N=S.length-1,E,q;if(N>=0&&(E=S[N])instanceof Q){if(!N&&E.type==v&&E.length==H)return E;(q=E.prop(P.lookAhead))&&($=R[N]+E.length+q)}return m(v,S,R,H,$,k)}}function p(v,k,S,R,H,$,N,E,q){let z=[],G=[];for(;v.length>R;)z.push(v.pop()),G.push(k.pop()+S-H);v.push(m(i.types[N],z,G,$-H,E-$,q)),k.push(H-S)}function m(v,k,S,R,H,$,N){if($){let E=[P.contextHash,$];N=N?[E].concat(N):[E]}if(H>25){let E=[P.lookAhead,H];N=N?[E].concat(N):[E]}return new Q(v,k,S,R,N)}function g(v,k){let S=l.fork(),R=0,H=0,$=0,N=S.end-s,E={size:0,start:0,skip:0};e:for(let q=S.pos-v;S.pos>q;){let z=S.size;if(S.id==k&&z>=0){E.size=R,E.start=H,E.skip=$,$+=4,R+=4,S.next();continue}let G=S.pos-z;if(z<0||G=o?4:0,Me=S.start;for(S.next();S.pos>G;){if(S.size<0)if(S.size==-3||S.size==-4)ge+=4;else break e;else S.id>=o&&(ge+=4);S.next()}H=Me,R+=z,$+=ge}return(k<0||R==v)&&(E.size=R,E.start=H,E.skip=$),E.size>4?E:void 0}function y(v,k,S){let{id:R,start:H,end:$,size:N}=l;if(l.next(),N>=0&&R4){let q=l.pos-(N-4);for(;l.pos>q;)S=y(v,k,S)}k[--S]=E,k[--S]=$-v,k[--S]=H-v,k[--S]=R}else N==-3?h=R:N==-4&&(c=R);return S}let x=[],w=[];for(;l.pos>0;)f(n.start||0,n.bufferStart||0,x,w,-1,0);let O=(e=n.length)!==null&&e!==void 0?e:x.length?w[0]+x[0].length:0;return new Q(a[n.topID],x.reverse(),w.reverse(),O)}var nf=new WeakMap;function Cs(n,e){if(!n.isAnonymous||e instanceof vt||e.type!=n)return 1;let t=nf.get(e);if(t==null){t=1;for(let i of e.children){if(i.type!=n||!(i instanceof Q)){t=1;break}t+=Cs(n,i)}nf.set(e,t)}return t}function al(n,e,t,i,s,r,o,l,a){let h=0;for(let p=i;p=c)break;k+=S}if(w==O+1){if(k>c){let S=p[O];d(S.children,S.positions,0,S.children.length,m[O]+x);continue}f.push(p[O])}else{let S=m[w-1]+p[w-1].length-v;f.push(al(n,p,m,O,w,v,S,null,a))}u.push(v+x-r)}}return d(e,t,i,s,0),(l||a)(f,u,o)}var jt=class n{constructor(e,t,i,s,r=!1,o=!1){this.from=e,this.to=t,this.tree=i,this.offset=s,this.open=(r?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],i=!1){let s=[new n(0,e.length,e,0,!1,i)];for(let r of t)r.to>e.length&&s.push(r);return s}static applyChanges(e,t,i=128){if(!t.length)return e;let s=[],r=1,o=e.length?e[0]:null;for(let l=0,a=0,h=0;;l++){let c=l=i)for(;o&&o.from=u.from||f<=u.to||h){let d=Math.max(u.from,a)-h,p=Math.min(u.to,f)-h;u=d>=p?null:new n(d,p,u.tree,u.offset+h,l>0,!!c)}if(u&&s.push(u),o.to>f)break;o=rnew en(s.from,s.to)):[new en(0,0)]:[new en(0,e.length)],this.createParse(e,t||[],i)}parse(e,t,i){let s=this.startParse(e,t,i);for(;;){let r=s.advance();if(r)return r}}},ol=class{constructor(e){this.string=e}get length(){return this.string.length}chunk(e){return this.string.slice(e)}get lineChunks(){return!1}read(e,t){return this.string.slice(e,t)}};var m1=new P({perNode:!0});var lg=0,je=class n{constructor(e,t,i,s){this.name=e,this.set=t,this.base=i,this.modified=s,this.id=lg++}toString(){let{name:e}=this;for(let t of this.modified)t.name&&(e=`${t.name}(${e})`);return e}static define(e,t){let i=typeof e=="string"?e:"?";if(e instanceof n&&(t=e),t?.base)throw new Error("Can not derive from a modified tag");let s=new n(i,[],null,[]);if(s.set.push(s),t)for(let r of t.set)s.set.push(r);return s}static defineModifier(e){let t=new Bs(e);return i=>i.modified.indexOf(t)>-1?i:Bs.get(i.base||i,i.modified.concat(t).sort((s,r)=>s.id-r.id))}},ag=0,Bs=class n{constructor(e){this.name=e,this.instances=[],this.id=ag++}static get(e,t){if(!t.length)return e;let i=t[0].instances.find(l=>l.base==e&&hg(t,l.modified));if(i)return i;let s=[],r=new je(e.name,s,e,t);for(let l of t)l.instances.push(r);let o=cg(t);for(let l of e.set)if(!l.modified.length)for(let a of o)s.push(n.get(l,a));return r}};function hg(n,e){return n.length==e.length&&n.every((t,i)=>t==e[i])}function cg(n){let e=[[]];for(let t=0;ti.length-t.length)}function af(n){let e=Object.create(null);for(let t in n){let i=n[t];Array.isArray(i)||(i=[i]);for(let s of t.split(" "))if(s){let r=[],o=2,l=s;for(let f=0;;){if(l=="..."&&f>0&&f+3==s.length){o=1;break}let u=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(l);if(!u)throw new RangeError("Invalid path: "+s);if(r.push(u[0]=="*"?"":u[0][0]=='"'?JSON.parse(u[0]):u[0]),f+=u[0].length,f==s.length)break;let d=s[f++];if(f==s.length&&d=="!"){o=0;break}if(d!="/")throw new RangeError("Invalid path: "+s);l=s.slice(f)}let a=r.length-1,h=r[a];if(!h)throw new RangeError("Invalid path: "+s);let c=new Gt(i,o,a>0?r.slice(0,a):null);e[h]=c.sort(e[h])}}return hf.add(e)}var hf=new P({combine(n,e){let t,i,s;for(;n||e;){if(!n||e&&n.depth>=e.depth?(s=e,e=e.next):(s=n,n=n.next),t&&t.mode==s.mode&&!s.context&&!t.context)continue;let r=new Gt(s.tags,s.mode,s.context);t?t.next=r:i=r,t=r}return i}}),Gt=class{constructor(e,t,i,s){this.tags=e,this.mode=t,this.context=i,this.next=s}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(e){return!e||e.depth{let o=s;for(let l of r)for(let a of l.set){let h=t[a.id];if(h){o=o?o+" "+h:h;break}}return o},scope:i}}function fg(n,e){let t=null;for(let i of n){let s=i.style(e);s&&(t=t?t+" "+s:s)}return t}function cf(n,e,t,i=0,s=n.length){let r=new cl(i,Array.isArray(e)?e:[e],t);r.highlightRange(n.cursor(),i,s,"",r.highlighters),r.flush(s)}var cl=class{constructor(e,t,i){this.at=e,this.highlighters=t,this.span=i,this.class=""}startSpan(e,t){t!=this.class&&(this.flush(e),e>this.at&&(this.at=e),this.class=t)}flush(e){e>this.at&&this.class&&this.span(this.at,e,this.class)}highlightRange(e,t,i,s,r){let{type:o,from:l,to:a}=e;if(l>=i||a<=t)return;o.isTop&&(r=this.highlighters.filter(d=>!d.scope||d.scope(o)));let h=s,c=ug(e)||Gt.empty,f=fg(r,c.tags);if(f&&(h&&(h+=" "),h+=f,c.mode==1&&(s+=(s?" ":"")+f)),this.startSpan(Math.max(t,l),h),c.opaque)return;let u=e.tree&&e.tree.prop(P.mounted);if(u&&u.overlay){let d=e.node.enter(u.overlay[0].from+l,1),p=this.highlighters.filter(g=>!g.scope||g.scope(u.tree.type)),m=e.firstChild();for(let g=0,y=l;;g++){let x=g=w||!e.nextSibling())););if(!x||w>i)break;y=x.to+l,y>t&&(this.highlightRange(d.cursor(),Math.max(t,x.from+l),Math.min(i,y),"",p),this.startSpan(Math.min(i,y),h))}m&&e.parent()}else if(e.firstChild()){u&&(s="");do if(!(e.to<=t)){if(e.from>=i)break;this.highlightRange(e,t,i,s,r),this.startSpan(Math.min(i,e.to),h)}while(e.nextSibling());e.parent()}}};function ug(n){let e=n.type.prop(hf);for(;e&&e.context&&!n.matchContext(e.context);)e=e.next;return e||null}var M=je.define,Ts=M(),kt=M(),of=M(kt),lf=M(kt),St=M(),Ds=M(St),hl=M(St),it=M(),Ut=M(it),et=M(),tt=M(),fl=M(),on=M(fl),Os=M(),T={comment:Ts,lineComment:M(Ts),blockComment:M(Ts),docComment:M(Ts),name:kt,variableName:M(kt),typeName:of,tagName:M(of),propertyName:lf,attributeName:M(lf),className:M(kt),labelName:M(kt),namespace:M(kt),macroName:M(kt),literal:St,string:Ds,docString:M(Ds),character:M(Ds),attributeValue:M(Ds),number:hl,integer:M(hl),float:M(hl),bool:M(St),regexp:M(St),escape:M(St),color:M(St),url:M(St),keyword:et,self:M(et),null:M(et),atom:M(et),unit:M(et),modifier:M(et),operatorKeyword:M(et),controlKeyword:M(et),definitionKeyword:M(et),moduleKeyword:M(et),operator:tt,derefOperator:M(tt),arithmeticOperator:M(tt),logicOperator:M(tt),bitwiseOperator:M(tt),compareOperator:M(tt),updateOperator:M(tt),definitionOperator:M(tt),typeOperator:M(tt),controlOperator:M(tt),punctuation:fl,separator:M(fl),bracket:on,angleBracket:M(on),squareBracket:M(on),paren:M(on),brace:M(on),content:it,heading:Ut,heading1:M(Ut),heading2:M(Ut),heading3:M(Ut),heading4:M(Ut),heading5:M(Ut),heading6:M(Ut),contentSeparator:M(it),list:M(it),quote:M(it),emphasis:M(it),strong:M(it),link:M(it),monospace:M(it),strikethrough:M(it),inserted:M(),deleted:M(),changed:M(),invalid:M(),meta:Os,documentMeta:M(Os),annotation:M(Os),processingInstruction:M(Os),definition:je.defineModifier("definition"),constant:je.defineModifier("constant"),function:je.defineModifier("function"),standard:je.defineModifier("standard"),local:je.defineModifier("local"),special:je.defineModifier("special")};for(let n in T){let e=T[n];e instanceof je&&(e.name=n)}var b1=ul([{tag:T.link,class:"tok-link"},{tag:T.heading,class:"tok-heading"},{tag:T.emphasis,class:"tok-emphasis"},{tag:T.strong,class:"tok-strong"},{tag:T.keyword,class:"tok-keyword"},{tag:T.atom,class:"tok-atom"},{tag:T.bool,class:"tok-bool"},{tag:T.url,class:"tok-url"},{tag:T.labelName,class:"tok-labelName"},{tag:T.inserted,class:"tok-inserted"},{tag:T.deleted,class:"tok-deleted"},{tag:T.literal,class:"tok-literal"},{tag:T.string,class:"tok-string"},{tag:T.number,class:"tok-number"},{tag:[T.regexp,T.escape,T.special(T.string)],class:"tok-string2"},{tag:T.variableName,class:"tok-variableName"},{tag:T.local(T.variableName),class:"tok-variableName tok-local"},{tag:T.definition(T.variableName),class:"tok-variableName tok-definition"},{tag:T.special(T.variableName),class:"tok-variableName2"},{tag:T.definition(T.propertyName),class:"tok-propertyName tok-definition"},{tag:T.typeName,class:"tok-typeName"},{tag:T.namespace,class:"tok-namespace"},{tag:T.className,class:"tok-className"},{tag:T.macroName,class:"tok-macroName"},{tag:T.propertyName,class:"tok-propertyName"},{tag:T.operator,class:"tok-operator"},{tag:T.comment,class:"tok-comment"},{tag:T.meta,class:"tok-meta"},{tag:T.invalid,class:"tok-invalid"},{tag:T.punctuation,class:"tok-punctuation"}]);var dl,Ct=new P;function Tl(n){return C.define({combine:n?e=>e.concat(n):void 0})}var vf=new P,ue=class{constructor(e,t,i=[],s=""){this.data=e,this.name=s,j.prototype.hasOwnProperty("tree")||Object.defineProperty(j.prototype,"tree",{get(){return Z(this)}}),this.parser=t,this.extension=[ft.of(this),j.languageData.of((r,o,l)=>{let a=ff(r,o,l),h=a.type.prop(Ct);if(!h)return[];let c=r.facet(h),f=a.type.prop(vf);if(f){let u=a.resolve(o-a.from,l);for(let d of f)if(d.test(u,r)){let p=r.facet(d.facet);return d.type=="replace"?p:p.concat(c)}}return c})].concat(i)}isActiveAt(e,t,i=-1){return ff(e,t,i).type.prop(Ct)==this.data}findRegions(e){let t=e.facet(ft);if(t?.data==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let i=[],s=(r,o)=>{if(r.prop(Ct)==this.data){i.push({from:o,to:o+r.length});return}let l=r.prop(P.mounted);if(l){if(l.tree.prop(Ct)==this.data){if(l.overlay)for(let a of l.overlay)i.push({from:a.from+o,to:a.to+o});else i.push({from:o,to:o+r.length});return}else if(l.overlay){let a=i.length;if(s(l.tree,l.overlay[0].from+o),i.length>a)return}}for(let a=0;ai.isTop?t:void 0)]}),e.name)}configure(e,t){return new n(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}};function Z(n){let e=n.field(ue.state,!1);return e?e.tree:Q.empty}function kf(n,e,t=50){var i;let s=(i=n.field(ue.state,!1))===null||i===void 0?void 0:i.context;if(!s)return null;let r=s.viewport;s.updateViewport({from:0,to:e});let o=s.isDone(e)||s.work(t,e)?s.tree:null;return s.updateViewport(r),o}function dg(n,e=n.doc.length){var t;return((t=n.field(ue.state,!1))===null||t===void 0?void 0:t.context.isDone(e))||!1}function pg(n,e=n.viewport.to,t=100){let i=kf(n.state,e,t);return i!=Z(n.state)&&n.dispatch({}),!!i}function mg(n){var e;return((e=n.plugin(Cf))===null||e===void 0?void 0:e.isWorking())||!1}var Es=class{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let i=this.cursorPos-this.string.length;return e=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-i,t-i)}},ln=null,vi=class n{constructor(e,t,i=[],s,r,o,l,a){this.parser=e,this.state=t,this.fragments=i,this.tree=s,this.treeLen=r,this.viewport=o,this.skipped=l,this.scheduleOn=a,this.parse=null,this.tempSkipped=[]}static create(e,t,i){return new n(e,t,[],Q.empty,0,i,[],null)}startParse(){return this.parser.startParse(new Es(this.state.doc),this.fragments)}work(e,t){return t!=null&&t>=this.state.doc.length&&(t=void 0),this.tree!=Q.empty&&this.isDone(t??this.state.doc.length)?(this.takeTree(),!0):this.withContext(()=>{var i;if(typeof e=="number"){let s=Date.now()+e;e=()=>Date.now()>s}for(this.parse||(this.parse=this.startParse()),t!=null&&(this.parse.stoppedAt==null||this.parse.stoppedAt>t)&&t=this.treeLen&&((this.parse.stoppedAt==null||this.parse.stoppedAt>e)&&this.parse.stopAt(e),this.withContext(()=>{for(;!(t=this.parse.advance()););}),this.treeLen=e,this.tree=t,this.fragments=this.withoutTempSkipped(jt.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(e){let t=ln;ln=this;try{return e()}finally{ln=t}}withoutTempSkipped(e){for(let t;t=this.tempSkipped.pop();)e=uf(e,t.from,t.to);return e}changes(e,t){let{fragments:i,tree:s,treeLen:r,viewport:o,skipped:l}=this;if(this.takeTree(),!e.empty){let a=[];if(e.iterChangedRanges((h,c,f,u)=>a.push({fromA:h,toA:c,fromB:f,toB:u})),i=jt.applyChanges(i,a),s=Q.empty,r=0,o={from:e.mapPos(o.from,-1),to:e.mapPos(o.to,1)},this.skipped.length){l=[];for(let h of this.skipped){let c=e.mapPos(h.from,1),f=e.mapPos(h.to,-1);ce.from&&(this.fragments=uf(this.fragments,s,r),this.skipped.splice(i--,1))}return this.skipped.length>=t?!1:(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(e,t){this.skipped.push({from:e,to:t})}static getSkippingParser(e){return new class extends rn{createParse(t,i,s){let r=s[0].from,o=s[s.length-1].to;return{parsedPos:r,advance(){let a=ln;if(a){for(let h of s)a.tempSkipped.push(h);e&&(a.scheduleOn=a.scheduleOn?Promise.all([a.scheduleOn,e]):e)}return this.parsedPos=o,new Q(ve.none,[],[],o-r)},stoppedAt:null,stopAt(){}}}}}isDone(e){e=Math.min(e,this.state.doc.length);let t=this.fragments;return this.treeLen>=e&&t.length&&t[0].from==0&&t[0].to>=e}static get(){return ln}};function uf(n,e,t){return jt.applyChanges(n,[{fromA:e,toA:t,fromB:e,toB:t}])}var hn=class n{constructor(e){this.context=e,this.tree=e.tree}apply(e){if(!e.docChanged&&this.tree==this.context.tree)return this;let t=this.context.changes(e.changes,e.state),i=this.context.treeLen==e.startState.doc.length?void 0:Math.max(e.changes.mapPos(this.context.treeLen),t.viewport.to);return t.work(20,i)||t.takeTree(),new n(t)}static init(e){let t=Math.min(3e3,e.doc.length),i=vi.create(e.facet(ft).parser,e,{from:0,to:t});return i.work(20,t)||i.takeTree(),new n(i)}};ue.state=J.define({create:hn.init,update(n,e){for(let t of e.effects)if(t.is(ue.setState))return t.value;return e.startState.facet(ft)!=e.state.facet(ft)?hn.init(e.state):n.apply(e)}});var Sf=n=>{let e=setTimeout(()=>n(),500);return()=>clearTimeout(e)};typeof requestIdleCallback<"u"&&(Sf=n=>{let e=-1,t=setTimeout(()=>{e=requestIdleCallback(n,{timeout:400})},100);return()=>e<0?clearTimeout(t):cancelIdleCallback(e)});var pl=typeof navigator<"u"&&(!((dl=navigator.scheduling)===null||dl===void 0)&&dl.isInputPending)?()=>navigator.scheduling.isInputPending():null,Cf=Y.fromClass(class{constructor(e){this.view=e,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(e){let t=this.view.state.field(ue.state).context;(t.updateViewport(e.view.viewport)||this.view.viewport.to>t.treeLen)&&this.scheduleWork(),(e.docChanged||e.selectionSet)&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(t)}scheduleWork(){if(this.working)return;let{state:e}=this.view,t=e.field(ue.state);(t.tree!=t.context.tree||!t.context.isDone(e.doc.length))&&(this.working=Sf(this.work))}work(e){this.working=null;let t=Date.now();if(this.chunkEnds+1e3,a=r.context.work(()=>pl&&pl()||Date.now()>o,s+(l?0:1e5));this.chunkBudget-=Date.now()-t,(a||this.chunkBudget<=0)&&(r.context.takeTree(),this.view.dispatch({effects:ue.setState.of(new hn(r.context))})),this.chunkBudget>0&&!(a&&!l)&&this.scheduleWork(),this.checkAsyncSchedule(r.context)}checkAsyncSchedule(e){e.scheduleOn&&(this.workScheduled++,e.scheduleOn.then(()=>this.scheduleWork()).catch(t=>re(this.view.state,t)).then(()=>this.workScheduled--),e.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),ft=C.define({combine(n){return n.length?n[0]:null},enables:n=>[ue.state,Cf,A.contentAttributes.compute([n],e=>{let t=e.facet(n);return t&&t.name?{"data-language":t.name}:{}})]}),yl=class{constructor(e,t=[]){this.language=e,this.support=t,this.extension=[e,t]}},bl=class n{constructor(e,t,i,s,r,o=void 0){this.name=e,this.alias=t,this.extensions=i,this.filename=s,this.loadFunc=r,this.support=o,this.loading=null}load(){return this.loading||(this.loading=this.loadFunc().then(e=>this.support=e,e=>{throw this.loading=null,e}))}static of(e){let{load:t,support:i}=e;if(!t){if(!i)throw new RangeError("Must pass either 'load' or 'support' to LanguageDescription.of");t=()=>Promise.resolve(i)}return new n(e.name,(e.alias||[]).concat(e.name).map(s=>s.toLowerCase()),e.extensions||[],e.filename,t,i)}static matchFilename(e,t){for(let s of e)if(s.filename&&s.filename.test(t))return s;let i=/\.([^.]+)$/.exec(t);if(i){for(let s of e)if(s.extensions.indexOf(i[1])>-1)return s}return null}static matchLanguageName(e,t,i=!0){t=t.toLowerCase();for(let s of e)if(s.alias.some(r=>r==t))return s;if(i)for(let s of e)for(let r of s.alias){let o=t.indexOf(r);if(o>-1&&(r.length>2||!/\w/.test(t[o-1])&&!/\w/.test(t[o+r.length])))return s}return null}},Af=C.define(),_t=C.define({combine:n=>{if(!n.length)return" ";let e=n[0];if(!e||/\S/.test(e)||Array.from(e).some(t=>t!=e[0]))throw new Error("Invalid indent unit: "+JSON.stringify(n[0]));return e}});function ut(n){let e=n.facet(_t);return e.charCodeAt(0)==9?n.tabSize*e.length:e.length}function At(n,e){let t="",i=n.tabSize,s=n.facet(_t)[0];if(s==" "){for(;e>=i;)t+=" ",e-=i;s=" "}for(let r=0;r=e?yg(n,t,e):null}function gg(n,e,t){let i=Object.create(null),s=new dt(n,{overrideIndentation:o=>{var l;return(l=i[o])!==null&&l!==void 0?l:-1}}),r=[];for(let o=e;o<=t;){let l=n.doc.lineAt(o);o=l.to+1;let a=Si(s,l.from);if(a==null)continue;/\S/.test(l.text)||(a=0);let h=/^\s*/.exec(l.text)[0],c=At(n,a);h!=c&&(i[l.from]=a,r.push({from:l.from,to:l.from+h.length,insert:c}))}return n.changes(r)}var dt=class{constructor(e,t={}){this.state=e,this.options=t,this.unit=ut(e)}lineAt(e,t=1){let i=this.state.doc.lineAt(e),{simulateBreak:s,simulateDoubleBreak:r}=this.options;return s!=null&&s>=i.from&&s<=i.to?r&&s==e?{text:"",from:e}:(t<0?s-1&&(r+=o-this.countColumn(i,i.search(/\S|$/))),r}countColumn(e,t=e.length){return ot(e,this.state.tabSize,t)}lineIndent(e,t=1){let{text:i,from:s}=this.lineAt(e,t),r=this.options.overrideIndentation;if(r){let o=r(s);if(o>-1)return o}return this.countColumn(i,i.search(/\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}},Dl=new P;function yg(n,e,t){let i=e.resolveStack(t),s=e.resolveInner(t,-1).resolve(t,0).enterUnfinishedNodesBefore(t);if(s!=i.node){let r=[];for(let o=s;o&&!(o.fromi.node.to||o.from==i.node.from&&o.type==i.node.type);o=o.parent)r.push(o);for(let o=r.length-1;o>=0;o--)i={node:r[o],next:i}}return Mf(i,n,t)}function Mf(n,e,t){for(let i=n;i;i=i.next){let s=xg(i.node);if(s)return s(Rs.create(e,t,i))}return 0}function bg(n){return n.pos==n.options.simulateBreak&&n.options.simulateDoubleBreak}function xg(n){let e=n.type.prop(Dl);if(e)return e;let t=n.firstChild,i;if(t&&(i=t.type.prop(P.closedBy))){let s=n.lastChild,r=s&&i.indexOf(s.name)>-1;return o=>Tf(o,!0,1,void 0,r&&!bg(o)?s.from:void 0)}return n.parent==null?wg:null}function wg(){return 0}var Rs=class n extends dt{constructor(e,t,i){super(e.state,e.options),this.base=e,this.pos=t,this.context=i}get node(){return this.context.node}static create(e,t,i){return new n(e,t,i)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){return this.baseIndentFor(this.node)}baseIndentFor(e){let t=this.state.doc.lineAt(e.from);for(;;){let i=e.resolve(t.from);for(;i.parent&&i.parent.from==i.from;)i=i.parent;if(vg(i,e))break;t=this.state.doc.lineAt(i.from)}return this.lineIndent(t.from)}continue(){return Mf(this.context.next,this.base,this.pos)}};function vg(n,e){for(let t=e;t;t=t.parent)if(n==t)return!0;return!1}function kg(n){let e=n.node,t=e.childAfter(e.from),i=e.lastChild;if(!t)return null;let s=n.options.simulateBreak,r=n.state.doc.lineAt(t.from),o=s==null||s<=r.from?r.to:Math.min(r.to,s);for(let l=t.to;;){let a=e.childAfter(l);if(!a||a==i)return null;if(!a.type.isSkipped){if(a.from>=o)return null;let h=/^ */.exec(r.text.slice(t.to-r.from))[0].length;return{from:t.from,to:t.to+h}}l=a.to}}function Sg({closing:n,align:e=!0,units:t=1}){return i=>Tf(i,e,t,n)}function Tf(n,e,t,i,s){let r=n.textAfter,o=r.match(/^\s*/)[0].length,l=i&&r.slice(o,o+i.length)==i||s==n.pos+o,a=e?kg(n):null;return a?l?n.column(a.from):n.column(a.to):n.baseIndent+(l?0:n.unit*t)}var Cg=n=>n.baseIndent;function Ag({except:n,units:e=1}={}){return t=>{let i=n&&n.test(t.textAfter);return t.baseIndent+(i?0:e*t.unit)}}var Mg=200;function Ol(){return j.transactionFilter.of(n=>{if(!n.docChanged||!n.isUserEvent("input.type")&&!n.isUserEvent("input.complete"))return n;let e=n.startState.languageDataAt("indentOnInput",n.startState.selection.main.head);if(!e.length)return n;let t=n.newDoc,{head:i}=n.newSelection.main,s=t.lineAt(i);if(i>s.from+Mg)return n;let r=t.sliceString(s.from,i);if(!e.some(h=>h.test(r)))return n;let{state:o}=n,l=-1,a=[];for(let{head:h}of o.selection.ranges){let c=o.doc.lineAt(h);if(c.from==l)continue;l=c.from;let f=Si(o,c.from);if(f==null)continue;let u=/^\s*/.exec(c.text)[0],d=At(o,f);u!=d&&a.push({from:c.from,to:c.from+u.length,insert:d})}return a.length?[n,{changes:a,sequential:!0}]:n})}var Df=C.define(),Of=new P;function Tg(n){let e=n.firstChild,t=n.lastChild;return e&&e.tot)continue;if(r&&l.from=e&&h.to>t&&(r=h)}}return r}function Og(n){let e=n.lastChild;return e&&e.to==n.to&&e.type.isError}function ki(n,e,t){for(let i of n.facet(Df)){let s=i(n,e,t);if(s)return s}return Dg(n,e,t)}function Bf(n,e){let t=e.mapPos(n.from,1),i=e.mapPos(n.to,-1);return t>=i?void 0:{from:t,to:i}}var Ci=L.define({map:Bf}),Yt=L.define({map:Bf});function Bl(n){let e=[];for(let{head:t}of n.state.selection.ranges)e.some(i=>i.from<=t&&i.to>=t)||e.push(n.lineBlockAt(t));return e}var pt=J.define({create(){return B.none},update(n,e){e.isUserEvent("delete")&&e.changes.iterChangedRanges((i,s)=>n=df(n,i,s)),n=n.map(e.changes);let t=[];for(let i of e.effects)i.is(Ci)&&!Lg(n,i.value.from,i.value.to)?t.push(i.value):i.is(Yt)&&(n=n.update({filter:(s,r)=>i.value.from!=s||i.value.to!=r,filterFrom:i.value.from,filterTo:i.value.to}));if(t.length){let{preparePlaceholder:i}=e.state.facet(Rl),s=t.map(r=>(i?B.replace({widget:new xl(i(e.state,r))}):pf).range(r.from,r.to));n=n.update({add:s})}return e.selection&&(n=df(n,e.selection.main.head)),n},provide:n=>A.decorations.from(n),toJSON(n,e){let t=[];return n.between(0,e.doc.length,(i,s)=>{t.push(i,s)}),t},fromJSON(n){if(!Array.isArray(n)||n.length%2)throw new RangeError("Invalid JSON for fold state");let e=[];for(let t=0;t{se&&(i=!0)}),i?n.update({filterFrom:e,filterTo:t,filter:(s,r)=>s>=t||r<=e}):n}function Bg(n){return n.field(pt,!1)||I.empty}function cn(n,e,t){var i;let s=null;return(i=n.field(pt,!1))===null||i===void 0||i.between(e,t,(r,o)=>{(!s||s.from>r)&&(s={from:r,to:o})}),s}function Lg(n,e,t){let i=!1;return n.between(e,e,(s,r)=>{s==e&&r==t&&(i=!0)}),i}function Ll(n,e){return n.field(pt,!1)?e:e.concat(L.appendConfig.of(Pl()))}var Lf=n=>{for(let e of Bl(n)){let t=ki(n.state,e.from,e.to);if(t)return n.dispatch({effects:Ll(n.state,[Ci.of(t),Ps(n,t)])}),!0}return!1},Ef=n=>{if(!n.state.field(pt,!1))return!1;let e=[];for(let t of Bl(n)){let i=cn(n.state,t.from,t.to);i&&e.push(Yt.of(i),Ps(n,i,!1))}return e.length&&n.dispatch({effects:e}),e.length>0};function Ps(n,e,t=!0){let i=n.state.doc.lineAt(e.from).number,s=n.state.doc.lineAt(e.to).number;return A.announce.of(`${n.state.phrase(t?"Folded lines":"Unfolded lines")} ${i} ${n.state.phrase("to")} ${s}.`)}var Rf=n=>{let{state:e}=n,t=[];for(let i=0;i{let e=n.state.field(pt,!1);if(!e||!e.size)return!1;let t=[];return e.between(0,n.state.doc.length,(i,s)=>{t.push(Yt.of({from:i,to:s}))}),n.dispatch({effects:t}),!0};function Eg(n,e){for(let t=e;;){let i=ki(n.state,t.from,t.to);if(i&&i.to>e.from)return i;if(!t.from)return null;t=n.lineBlockAt(t.from-1)}}var Rg=n=>{let e=[];for(let t of Bl(n)){let i=cn(n.state,t.from,t.to);if(i)e.push(Yt.of(i),Ps(n,i,!1));else{let s=Eg(n,t);s&&e.push(Ci.of(s),Ps(n,s))}}return e.length>0&&n.dispatch({effects:Ll(n.state,e)}),!!e.length},El=[{key:"Ctrl-Shift-[",mac:"Cmd-Alt-[",run:Lf},{key:"Ctrl-Shift-]",mac:"Cmd-Alt-]",run:Ef},{key:"Ctrl-Alt-[",run:Rf},{key:"Ctrl-Alt-]",run:Pf}],Pg={placeholderDOM:null,preparePlaceholder:null,placeholderText:"\u2026"},Rl=C.define({combine(n){return ce(n,Pg)}});function Pl(n){let e=[pt,Ng];return n&&e.push(Rl.of(n)),e}function If(n,e){let{state:t}=n,i=t.facet(Rl),s=o=>{let l=n.lineBlockAt(n.posAtDOM(o.target)),a=cn(n.state,l.from,l.to);a&&n.dispatch({effects:Yt.of(a)}),o.preventDefault()};if(i.placeholderDOM)return i.placeholderDOM(n,s,e);let r=document.createElement("span");return r.textContent=i.placeholderText,r.setAttribute("aria-label",t.phrase("folded code")),r.title=t.phrase("unfold"),r.className="cm-foldPlaceholder",r.onclick=s,r}var pf=B.replace({widget:new class extends xe{toDOM(n){return If(n,null)}}}),xl=class extends xe{constructor(e){super(),this.value=e}eq(e){return this.value==e.value}toDOM(e){return If(e,this.value)}},Ig={openText:"\u2304",closedText:"\u203A",markerDOM:null,domEventHandlers:{},foldingChanged:()=>!1},an=class extends Ae{constructor(e,t){super(),this.config=e,this.open=t}eq(e){return this.config==e.config&&this.open==e.open}toDOM(e){if(this.config.markerDOM)return this.config.markerDOM(this.open);let t=document.createElement("span");return t.textContent=this.open?this.config.openText:this.config.closedText,t.title=e.state.phrase(this.open?"Fold line":"Unfold line"),t}};function Il(n={}){let e={...Ig,...n},t=new an(e,!0),i=new an(e,!1),s=Y.fromClass(class{constructor(o){this.from=o.viewport.from,this.markers=this.buildMarkers(o)}update(o){(o.docChanged||o.viewportChanged||o.startState.facet(ft)!=o.state.facet(ft)||o.startState.field(pt,!1)!=o.state.field(pt,!1)||Z(o.startState)!=Z(o.state)||e.foldingChanged(o))&&(this.markers=this.buildMarkers(o.view))}buildMarkers(o){let l=new Se;for(let a of o.viewportLineBlocks){let h=cn(o.state,a.from,a.to)?i:ki(o.state,a.from,a.to)?t:null;h&&l.add(a.from,a.from,h)}return l.finish()}}),{domEventHandlers:r}=e;return[s,ks({class:"cm-foldGutter",markers(o){var l;return((l=o.plugin(s))===null||l===void 0?void 0:l.markers)||I.empty},initialSpacer(){return new an(e,!1)},domEventHandlers:{...r,click:(o,l,a)=>{if(r.click&&r.click(o,l,a))return!0;let h=cn(o.state,l.from,l.to);if(h)return o.dispatch({effects:Yt.of(h)}),!0;let c=ki(o.state,l.from,l.to);return c?(o.dispatch({effects:Ci.of(c)}),!0):!1}}}),Pl()]}var Ng=A.baseTheme({".cm-foldPlaceholder":{backgroundColor:"#eee",border:"1px solid #ddd",color:"#888",borderRadius:".2em",margin:"0 1px",padding:"0 1px",cursor:"pointer"},".cm-foldGutter span":{padding:"0 1px",cursor:"pointer"}}),fn=class n{constructor(e,t){this.specs=e;let i;function s(l){let a=Ie.newName();return(i||(i=Object.create(null)))["."+a]=l,a}let r=typeof t.all=="string"?t.all:t.all?s(t.all):void 0,o=t.scope;this.scope=o instanceof ue?l=>l.prop(Ct)==o.data:o?l=>l==o:void 0,this.style=ul(e.map(l=>({tag:l.tag,class:l.class||s(Object.assign({},l,{tag:null}))})),{all:r}).style,this.module=i?new Ie(i):null,this.themeType=t.themeType}static define(e,t){return new n(e,t||{})}},wl=C.define(),Nf=C.define({combine(n){return n.length?[n[0]]:null}});function Ls(n){let e=n.facet(wl);return e.length?e:n.facet(Nf)}function Ns(n,e){let t=[Hg],i;return n instanceof fn&&(n.module&&t.push(A.styleModule.of(n.module)),i=n.themeType),e?.fallback?t.push(Nf.of(n)):i?t.push(wl.computeN([A.darkTheme],s=>s.facet(A.darkTheme)==(i=="dark")?[n]:[])):t.push(wl.of(n)),t}function Fg(n,e,t){let i=Ls(n),s=null;if(i){for(let r of i)if(!r.scope||t&&r.scope(t)){let o=r.style(e);o&&(s=s?s+" "+o:o)}}return s}var vl=class{constructor(e){this.markCache=Object.create(null),this.tree=Z(e.state),this.decorations=this.buildDeco(e,Ls(e.state)),this.decoratedTo=e.viewport.to}update(e){let t=Z(e.state),i=Ls(e.state),s=i!=Ls(e.startState),{viewport:r}=e.view,o=e.changes.mapPos(this.decoratedTo,1);t.length=r.to?(this.decorations=this.decorations.map(e.changes),this.decoratedTo=o):(t!=this.tree||e.viewportChanged||s)&&(this.tree=t,this.decorations=this.buildDeco(e.view,i),this.decoratedTo=r.to)}buildDeco(e,t){if(!t||!this.tree.length)return B.none;let i=new Se;for(let{from:s,to:r}of e.visibleRanges)cf(this.tree,t,(o,l,a)=>{i.add(o,l,this.markCache[a]||(this.markCache[a]=B.mark({class:a})))},s,r);return i.finish()}},Hg=Oe.high(Y.fromClass(vl,{decorations:n=>n.decorations})),Fs=fn.define([{tag:T.meta,color:"#404740"},{tag:T.link,textDecoration:"underline"},{tag:T.heading,textDecoration:"underline",fontWeight:"bold"},{tag:T.emphasis,fontStyle:"italic"},{tag:T.strong,fontWeight:"bold"},{tag:T.strikethrough,textDecoration:"line-through"},{tag:T.keyword,color:"#708"},{tag:[T.atom,T.bool,T.url,T.contentSeparator,T.labelName],color:"#219"},{tag:[T.literal,T.inserted],color:"#164"},{tag:[T.string,T.deleted],color:"#a11"},{tag:[T.regexp,T.escape,T.special(T.string)],color:"#e40"},{tag:T.definition(T.variableName),color:"#00f"},{tag:T.local(T.variableName),color:"#30a"},{tag:[T.typeName,T.namespace],color:"#085"},{tag:T.className,color:"#167"},{tag:[T.special(T.variableName),T.macroName],color:"#256"},{tag:T.definition(T.propertyName),color:"#00c"},{tag:T.comment,color:"#940"},{tag:T.invalid,color:"#f00"}]),Wg=A.baseTheme({"&.cm-focused .cm-matchingBracket":{backgroundColor:"#328c8252"},"&.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bb555544"}}),Ff=1e4,Hf="()[]{}",Wf=C.define({combine(n){return ce(n,{afterCursor:!0,brackets:Hf,maxScanDistance:Ff,renderMatch:qg})}}),Vg=B.mark({class:"cm-matchingBracket"}),zg=B.mark({class:"cm-nonmatchingBracket"});function qg(n){let e=[],t=n.matched?Vg:zg;return e.push(t.range(n.start.from,n.start.to)),n.end&&e.push(t.range(n.end.from,n.end.to)),e}function mf(n){let e=[],t=n.facet(Wf);for(let i of n.selection.ranges){if(!i.empty)continue;let s=We(n,i.head,-1,t)||i.head>0&&We(n,i.head-1,1,t)||t.afterCursor&&(We(n,i.head,1,t)||i.headn.decorations}),$g=[Kg,Wg];function Nl(n={}){return[Wf.of(n),$g]}var Vf=new P;function kl(n,e,t){let i=n.prop(e<0?P.openedBy:P.closedBy);if(i)return i;if(n.name.length==1){let s=t.indexOf(n.name);if(s>-1&&s%2==(e<0?1:0))return[t[s+e]]}return null}function Sl(n){let e=n.type.prop(Vf);return e?e(n.node):n}function We(n,e,t,i={}){let s=i.maxScanDistance||Ff,r=i.brackets||Hf,o=Z(n),l=o.resolveInner(e,t);for(let a=l;a;a=a.parent){let h=kl(a.type,t,r);if(h&&a.from0?e>=c.from&&ec.from&&e<=c.to))return jg(n,e,t,a,c,h,r)}}return Ug(n,e,t,o,l.type,s,r)}function jg(n,e,t,i,s,r,o){let l=i.parent,a={from:s.from,to:s.to},h=0,c=l?.cursor();if(c&&(t<0?c.childBefore(i.from):c.childAfter(i.to)))do if(t<0?c.to<=i.from:c.from>=i.to){if(h==0&&r.indexOf(c.type.name)>-1&&c.from0)return null;let h={from:t<0?e-1:e,to:t>0?e+1:e},c=n.doc.iterRange(e,t>0?n.doc.length:0),f=0;for(let u=0;!c.next().done&&u<=r;){let d=c.value;t<0&&(u+=d.length);let p=e+u*t;for(let m=t>0?0:d.length-1,g=t>0?d.length:-1;m!=g;m+=t){let y=o.indexOf(d[m]);if(!(y<0||i.resolveInner(p+m,1).type!=s))if(y%2==0==t>0)f++;else{if(f==1)return{start:h,end:{from:p+m,to:p+m+1},matched:y>>1==a>>1};f--}}t>0&&(u+=d.length)}return c.done?{start:h,matched:!1}:null}function gf(n,e,t,i=0,s=0){e==null&&(e=n.search(/[^\s\u00a0]/),e==-1&&(e=n.length));let r=s;for(let o=i;o=this.string.length}sol(){return this.pos==0}peek(){return this.string.charAt(this.pos)||void 0}next(){if(this.post}eatSpace(){let e=this.pos;for(;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e}skipToEnd(){this.pos=this.string.length}skipTo(e){let t=this.string.indexOf(e,this.pos);if(t>-1)return this.pos=t,!0}backUp(e){this.pos-=e}column(){return this.lastColumnPosi?o.toLowerCase():o,r=this.string.substr(this.pos,e.length);return s(r)==s(e)?(t!==!1&&(this.pos+=e.length),!0):null}else{let s=this.string.slice(this.pos).match(e);return s&&s.index>0?null:(s&&t!==!1&&(this.pos+=s[0].length),s)}}current(){return this.string.slice(this.start,this.pos)}};function Gg(n){return{name:n.name||"",token:n.token,blankLine:n.blankLine||(()=>{}),startState:n.startState||(()=>!0),copyState:n.copyState||_g,indent:n.indent||(()=>null),languageData:n.languageData||{},tokenTable:n.tokenTable||Hl,mergeTokens:n.mergeTokens!==!1}}function _g(n){if(typeof n!="object")return n;let e={};for(let t in n){let i=n[t];e[t]=i instanceof Array?i.slice():i}return e}var yf=new WeakMap,Cl=class n extends ue{constructor(e){let t=Tl(e.languageData),i=Gg(e),s,r=new class extends rn{createParse(o,l,a){return new Al(s,o,l,a)}};super(t,r,[],e.name),this.topNode=Qg(t,this),s=this,this.streamParser=i,this.stateAfter=new P({perNode:!0}),this.tokenTable=e.tokenTable?new Is(i.tokenTable):Xg}static define(e){return new n(e)}getIndent(e){let t,{overrideIndentation:i}=e.options;i&&(t=yf.get(e.state),t!=null&&t1e4)return null;for(;r=i&&t+e.length<=s&&e.prop(n.stateAfter);if(r)return{state:n.streamParser.copyState(r),pos:t+e.length};for(let o=e.children.length-1;o>=0;o--){let l=e.children[o],a=t+e.positions[o],h=l instanceof Q&&a=e.length)return e;!s&&t==0&&e.type==n.topNode&&(s=!0);for(let r=e.children.length-1;r>=0;r--){let o=e.positions[r],l=e.children[r],a;if(ot&&Fl(n,r.tree,0-r.offset,t,l),h;if(a&&a.pos<=i&&(h=zf(n,r.tree,t+r.offset,a.pos+r.offset,!1)))return{state:a.state,tree:h}}return{state:n.streamParser.startState(s?ut(s):4),tree:Q.empty}}var Al=class{constructor(e,t,i,s){this.lang=e,this.input=t,this.fragments=i,this.ranges=s,this.stoppedAt=null,this.chunks=[],this.chunkPos=[],this.chunk=[],this.chunkReused=void 0,this.rangeIndex=0,this.to=s[s.length-1].to;let r=vi.get(),o=s[0].from,{state:l,tree:a}=Yg(e,i,o,this.to,r?.state);this.state=l,this.parsedPos=this.chunkStart=o+a.length;for(let h=0;hh.from<=r.viewport.from&&h.to>=r.viewport.from)&&(this.state=this.lang.streamParser.startState(ut(r.state)),r.skipUntilInView(this.parsedPos,r.viewport.from),this.parsedPos=r.viewport.from),this.moveRangeIndex()}advance(){let e=vi.get(),t=this.stoppedAt==null?this.to:Math.min(this.to,this.stoppedAt),i=Math.min(t,this.chunkStart+512);for(e&&(i=Math.min(i,e.viewport.to));this.parsedPos=t?this.finish():e&&this.parsedPos>=e.viewport.to?(e.skipUntilInView(this.parsedPos,t),this.finish()):null}stopAt(e){this.stoppedAt=e}lineAfter(e){let t=this.input.chunk(e);if(this.input.lineChunks)t==` +`&&(t="");else{let i=t.indexOf(` +`);i>-1&&(t=t.slice(0,i))}return e+t.length<=this.to?t:t.slice(0,this.to-e)}nextLine(){let e=this.parsedPos,t=this.lineAfter(e),i=e+t.length;for(let s=this.rangeIndex;;){let r=this.ranges[s].to;if(r>=i||(t=t.slice(0,r-(i-t.length)),s++,s==this.ranges.length))break;let o=this.ranges[s].from,l=this.lineAfter(o);t+=l,i=o+l.length}return{line:t,end:i}}skipGapsTo(e,t,i){for(;;){let s=this.ranges[this.rangeIndex].to,r=e+t;if(i>0?s>r:s>=r)break;let o=this.ranges[++this.rangeIndex].from;t+=o-s}return t}moveRangeIndex(){for(;this.ranges[this.rangeIndex].to1){s=this.skipGapsTo(t,s,1),t+=s;let l=this.chunk.length;s=this.skipGapsTo(i,s,-1),i+=s,r+=this.chunk.length-l}let o=this.chunk.length-4;return this.lang.streamParser.mergeTokens&&r==4&&o>=0&&this.chunk[o]==e&&this.chunk[o+2]==t?this.chunk[o+2]=i:this.chunk.push(e,t,i,r),s}parseLine(e){let{line:t,end:i}=this.nextLine(),s=0,{streamParser:r}=this.lang,o=new un(t,e?e.state.tabSize:4,e?ut(e.state):2);if(o.eol())r.blankLine(this.state,o.indentUnit);else for(;!o.eol();){let l=qf(r.token,o,this.state);if(l&&(s=this.emitToken(this.lang.tokenTable.resolve(l),this.parsedPos+o.start,this.parsedPos+o.pos,s)),o.start>1e4)break}this.parsedPos=i,this.moveRangeIndex(),this.parsedPose.start)return s}throw new Error("Stream parser failed to advance stream.")}var Hl=Object.create(null),dn=[ve.none],Jg=new As(dn),bf=[],xf=Object.create(null),Kf=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])Kf[n]=$f(Hl,e);var Is=class{constructor(e){this.extra=e,this.table=Object.assign(Object.create(null),Kf)}resolve(e){return e?this.table[e]||(this.table[e]=$f(this.extra,e)):0}},Xg=new Is(Hl);function ml(n,e){bf.indexOf(n)>-1||(bf.push(n),console.warn(e))}function $f(n,e){let t=[];for(let l of e.split(" ")){let a=[];for(let h of l.split(".")){let c=n[h]||T[h];c?typeof c=="function"?a.length?a=a.map(c):ml(h,`Modifier ${h} used at start of tag`):a.length?ml(h,`Tag ${h} used as modifier`):a=Array.isArray(c)?c:[c]:ml(h,`Unknown highlighting tag ${h}`)}for(let h of a)t.push(h)}if(!t.length)return 0;let i=e.replace(/ /g,"_"),s=i+" "+t.map(l=>l.id),r=xf[s];if(r)return r.id;let o=xf[s]=ve.define({id:dn.length,name:i,props:[af({[i]:t})]});return dn.push(o),o.id}function Qg(n,e){let t=ve.define({id:dn.length,name:"Document",props:[Ct.add(()=>n),Dl.add(()=>i=>e.getIndent(i))],top:!0});return dn.push(t),t}function jf(n){return n.length<=4096&&/[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/.test(n)}function Uf(n){for(let e=n.iter();!e.next().done;)if(jf(e.value))return!0;return!1}function Zg(n){let e=!1;return n.iterChanges((t,i,s,r,o)=>{!e&&Uf(o)&&(e=!0)}),e}var Ml=C.define({combine:n=>n.some(e=>e)});function e0(n={}){let e=[t0];return n.alwaysIsolate&&e.push(Ml.of(!0)),e}var t0=Y.fromClass(class{constructor(n){this.always=n.state.facet(Ml)||n.textDirection!=V.LTR||n.state.facet(A.perLineTextDirection),this.hasRTL=!this.always&&Uf(n.state.doc),this.tree=Z(n.state),this.decorations=this.always||this.hasRTL?wf(n,this.tree,this.always):B.none}update(n){let e=n.state.facet(Ml)||n.view.textDirection!=V.LTR||n.state.facet(A.perLineTextDirection);if(!e&&!this.hasRTL&&Zg(n.changes)&&(this.hasRTL=!0),!e&&!this.hasRTL)return;let t=Z(n.state);(e!=this.always||t!=this.tree||n.docChanged||n.viewportChanged)&&(this.tree=t,this.always=e,this.decorations=wf(n.view,t,e))}},{provide:n=>{function e(t){var i,s;return(s=(i=t.plugin(n))===null||i===void 0?void 0:i.decorations)!==null&&s!==void 0?s:B.none}return[A.outerDecorations.of(e),Oe.lowest(A.bidiIsolatedRanges.of(e))]}});function wf(n,e,t){let i=new Se,s=n.visibleRanges;t||(s=i0(s,n.state.doc));for(let{from:r,to:o}of s)e.iterate({enter:l=>{let a=l.type.prop(P.isolate);a&&i.add(l.from,l.to,n0[a])},from:r,to:o});return i.finish()}function i0(n,e){let t=e.iter(),i=0,s=[],r=null;for(let{from:o,to:l}of n)if(!(r&&r.to>o&&(o=r.to,o>=l)))for(i+t.value.lengtha-10?r.to=Math.min(l,h):s.push(r={from:a,to:Math.min(l,h)})),h>=l)break;i=h,t.next()}return s}var n0={rtl:B.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"rtl"},bidiIsolate:V.RTL}),ltr:B.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"ltr"},bidiIsolate:V.LTR}),auto:B.mark({class:"cm-iso",inclusive:!0,attributes:{dir:"auto"},bidiIsolate:null})};var s0=n=>{let{state:e}=n,t=e.doc.lineAt(e.selection.main.from),i=jl(n.state,t.from);return i.line?r0(n):i.block?l0(n):!1};function $l(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let s=n(e,t);return s?(i(t.update(s)),!0):!1}}var r0=$l(c0,0);var o0=$l(iu,0);var l0=$l((n,e)=>iu(n,e,h0(e)),0);function jl(n,e){let t=n.languageDataAt("commentTokens",e,1);return t.length?t[0]:{}}var pn=50;function a0(n,{open:e,close:t},i,s){let r=n.sliceDoc(i-pn,i),o=n.sliceDoc(s,s+pn),l=/\s*$/.exec(r)[0].length,a=/^\s*/.exec(o)[0].length,h=r.length-l;if(r.slice(h-e.length,h)==e&&o.slice(a,a+t.length)==t)return{open:{pos:i-l,margin:l&&1},close:{pos:s+a,margin:a&&1}};let c,f;s-i<=2*pn?c=f=n.sliceDoc(i,s):(c=n.sliceDoc(i,i+pn),f=n.sliceDoc(s-pn,s));let u=/^\s*/.exec(c)[0].length,d=/\s*$/.exec(f)[0].length,p=f.length-d-t.length;return c.slice(u,u+e.length)==e&&f.slice(p,p+t.length)==t?{open:{pos:i+u+e.length,margin:/\s/.test(c.charAt(u+e.length))?1:0},close:{pos:s-d-t.length,margin:/\s/.test(f.charAt(p-1))?1:0}}:null}function h0(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),s=t.to<=i.to?i:n.doc.lineAt(t.to);s.from>i.from&&s.from==t.to&&(s=t.to==i.to+1?i:n.doc.lineAt(t.to-1));let r=e.length-1;r>=0&&e[r].to>i.from?e[r].to=s.to:e.push({from:i.from+/^\s*/.exec(i.text)[0].length,to:s.to})}return e}function iu(n,e,t=e.selection.ranges){let i=t.map(r=>jl(e,r.from).block);if(!i.every(r=>r))return null;let s=t.map((r,o)=>a0(e,i[o],r.from,r.to));if(n!=2&&!s.every(r=>r))return{changes:e.changes(t.map((r,o)=>s[o]?[]:[{from:r.from,insert:i[o].open+" "},{from:r.to,insert:" "+i[o].close}]))};if(n!=1&&s.some(r=>r)){let r=[];for(let o=0,l;os&&(r==o||o>f.from)){s=f.from;let u=/^\s*/.exec(f.text)[0].length,d=u==f.length,p=f.text.slice(u,u+h.length)==h?u:-1;ur.comment<0&&(!r.empty||r.single))){let r=[];for(let{line:l,token:a,indent:h,empty:c,single:f}of i)(f||!c)&&r.push({from:l.from+h,insert:a+" "});let o=e.changes(r);return{changes:o,selection:e.selection.map(o,1)}}else if(n!=1&&i.some(r=>r.comment>=0)){let r=[];for(let{line:o,comment:l,token:a}of i)if(l>=0){let h=o.from+l,c=h+a.length;o.text[c-o.from]==" "&&c++,r.push({from:h,to:c})}return{changes:r}}return null}var Vl=ke.define(),f0=ke.define(),u0=C.define(),nu=C.define({combine(n){return ce(n,{minDepth:100,newGroupDelay:500,joinToEvent:(e,t)=>t},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(e,t)=>(i,s)=>e(i,s)||t(i,s)})}}),su=J.define({create(){return Jt.empty},update(n,e){let t=e.state.facet(nu),i=e.annotation(Vl);if(i){let a=Ue.fromTransaction(e,i.selection),h=i.side,c=h==0?n.undone:n.done;return a?c=Ws(c,c.length,t.minDepth,a):c=lu(c,e.startState.selection),new Jt(h==0?i.rest:c,h==0?c:i.rest)}let s=e.annotation(f0);if((s=="full"||s=="before")&&(n=n.isolate()),e.annotation(ie.addToHistory)===!1)return e.changes.empty?n:n.addMapping(e.changes.desc);let r=Ue.fromTransaction(e),o=e.annotation(ie.time),l=e.annotation(ie.userEvent);return r?n=n.addChanges(r,o,l,t,e):e.selection&&(n=n.addSelection(e.startState.selection,o,l,t.newGroupDelay)),(s=="full"||s=="after")&&(n=n.isolate()),n},toJSON(n){return{done:n.done.map(e=>e.toJSON()),undone:n.undone.map(e=>e.toJSON())}},fromJSON(n){return new Jt(n.done.map(Ue.fromJSON),n.undone.map(Ue.fromJSON))}});function Ul(n={}){return[su,nu.of(n),A.domEventHandlers({beforeinput(e,t){let i=e.inputType=="historyUndo"?ru:e.inputType=="historyRedo"?zl:null;return i?(e.preventDefault(),i(t)):!1}})]}function Vs(n,e){return function({state:t,dispatch:i}){if(!e&&t.readOnly)return!1;let s=t.field(su,!1);if(!s)return!1;let r=s.pop(n,t,e);return r?(i(r),!0):!1}}var ru=Vs(0,!1),zl=Vs(1,!1),d0=Vs(0,!0),p0=Vs(1,!0);var Ue=class n{constructor(e,t,i,s,r){this.changes=e,this.effects=t,this.mapped=i,this.startSelection=s,this.selectionsAfter=r}setSelAfter(e){return new n(this.changes,this.effects,this.mapped,this.startSelection,e)}toJSON(){var e,t,i;return{changes:(e=this.changes)===null||e===void 0?void 0:e.toJSON(),mapped:(t=this.mapped)===null||t===void 0?void 0:t.toJSON(),startSelection:(i=this.startSelection)===null||i===void 0?void 0:i.toJSON(),selectionsAfter:this.selectionsAfter.map(s=>s.toJSON())}}static fromJSON(e){return new n(e.changes&&ye.fromJSON(e.changes),[],e.mapped&&rt.fromJSON(e.mapped),e.startSelection&&b.fromJSON(e.startSelection),e.selectionsAfter.map(b.fromJSON))}static fromTransaction(e,t){let i=Ve;for(let s of e.startState.facet(u0)){let r=s(e);r.length&&(i=i.concat(r))}return!i.length&&e.changes.empty?null:new n(e.changes.invert(e.startState.doc),i,void 0,t||e.startState.selection,Ve)}static selection(e){return new n(void 0,Ve,void 0,void 0,e)}};function Ws(n,e,t,i){let s=e+1>t+20?e-t-1:0,r=n.slice(s,e);return r.push(i),r}function m0(n,e){let t=[],i=!1;return n.iterChangedRanges((s,r)=>t.push(s,r)),e.iterChangedRanges((s,r,o,l)=>{for(let a=0;a=h&&o<=c&&(i=!0)}}),i}function g0(n,e){return n.ranges.length==e.ranges.length&&n.ranges.filter((t,i)=>t.empty!=e.ranges[i].empty).length===0}function ou(n,e){return n.length?e.length?n.concat(e):n:e}var Ve=[],y0=200;function lu(n,e){if(n.length){let t=n[n.length-1],i=t.selectionsAfter.slice(Math.max(0,t.selectionsAfter.length-y0));return i.length&&i[i.length-1].eq(e)?n:(i.push(e),Ws(n,n.length-1,1e9,t.setSelAfter(i)))}else return[Ue.selection([e])]}function b0(n){let e=n[n.length-1],t=n.slice();return t[n.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),t}function Wl(n,e){if(!n.length)return n;let t=n.length,i=Ve;for(;t;){let s=x0(n[t-1],e,i);if(s.changes&&!s.changes.empty||s.effects.length){let r=n.slice(0,t);return r[t-1]=s,r}else e=s.mapped,t--,i=s.selectionsAfter}return i.length?[Ue.selection(i)]:Ve}function x0(n,e,t){let i=ou(n.selectionsAfter.length?n.selectionsAfter.map(l=>l.map(e)):Ve,t);if(!n.changes)return Ue.selection(i);let s=n.changes.map(e),r=e.mapDesc(n.changes,!0),o=n.mapped?n.mapped.composeDesc(r):r;return new Ue(s,L.mapEffects(n.effects,e),o,n.startSelection.map(r),i)}var w0=/^(input\.type|delete)($|\.)/,Jt=class n{constructor(e,t,i=0,s=void 0){this.done=e,this.undone=t,this.prevTime=i,this.prevUserEvent=s}isolate(){return this.prevTime?new n(this.done,this.undone):this}addChanges(e,t,i,s,r){let o=this.done,l=o[o.length-1];return l&&l.changes&&!l.changes.empty&&e.changes&&(!i||w0.test(i))&&(!l.selectionsAfter.length&&t-this.prevTime0&&t-this.prevTimet.empty?n.moveByChar(t,e):zs(t,e))}function pe(n){return n.textDirectionAt(n.state.selection.main.head)==V.LTR}var hu=n=>au(n,!pe(n)),cu=n=>au(n,pe(n));function fu(n,e){return _e(n,t=>t.empty?n.moveByGroup(t,e):zs(t,e))}var v0=n=>fu(n,!pe(n)),k0=n=>fu(n,pe(n));var N1=typeof Intl<"u"&&Intl.Segmenter?new Intl.Segmenter(void 0,{granularity:"word"}):null;function S0(n,e,t){if(e.type.prop(t))return!0;let i=e.to-e.from;return i&&(i>2||/[^\s,.;:]/.test(n.sliceDoc(e.from,e.to)))||e.firstChild}function qs(n,e,t){let i=Z(n).resolveInner(e.head),s=t?P.closedBy:P.openedBy;for(let a=e.head;;){let h=t?i.childAfter(a):i.childBefore(a);if(!h)break;S0(n,h,s)?i=h:a=t?h.to:h.from}let r=i.type.prop(s),o,l;return r&&(o=t?We(n,i.from,1):We(n,i.to,-1))&&o.matched?l=t?o.end.to:o.end.from:l=t?i.to:i.from,b.cursor(l,t?-1:1)}var C0=n=>_e(n,e=>qs(n.state,e,!pe(n))),A0=n=>_e(n,e=>qs(n.state,e,pe(n)));function uu(n,e){return _e(n,t=>{if(!t.empty)return zs(t,e);let i=n.moveVertically(t,e);return i.head!=t.head?i:n.moveToLineBoundary(t,e)})}var du=n=>uu(n,!1),pu=n=>uu(n,!0);function mu(n){let e=n.scrollDOM.clientHeighto.empty?n.moveVertically(o,e,t.height):zs(o,e));if(s.eq(i.selection))return!1;let r;if(t.selfScroll){let o=n.coordsAtPos(i.selection.main.head),l=n.scrollDOM.getBoundingClientRect(),a=l.top+t.marginTop,h=l.bottom-t.marginBottom;o&&o.top>a&&o.bottomgu(n,!1),ql=n=>gu(n,!0);function Mt(n,e,t){let i=n.lineBlockAt(e.head),s=n.moveToLineBoundary(e,t);if(s.head==e.head&&s.head!=(t?i.to:i.from)&&(s=n.moveToLineBoundary(e,t,!1)),!t&&s.head==i.from&&i.length){let r=/^\s*/.exec(n.state.sliceDoc(i.from,Math.min(i.from+100,i.to)))[0].length;r&&e.head!=i.from+r&&(s=b.cursor(i.from+r))}return s}var M0=n=>_e(n,e=>Mt(n,e,!0)),T0=n=>_e(n,e=>Mt(n,e,!1)),D0=n=>_e(n,e=>Mt(n,e,!pe(n))),O0=n=>_e(n,e=>Mt(n,e,pe(n))),B0=n=>_e(n,e=>b.cursor(n.lineBlockAt(e.head).from,1)),L0=n=>_e(n,e=>b.cursor(n.lineBlockAt(e.head).to,-1));function E0(n,e,t){let i=!1,s=Ai(n.selection,r=>{let o=We(n,r.head,-1)||We(n,r.head,1)||r.head>0&&We(n,r.head-1,1)||r.headE0(n,e,!1);function ze(n,e,t){let i=Ai(n.state.selection,s=>{s.undirectional&&s.head>=s.anchor!=e&&(s=b.range(s.head,s.anchor));let r=t(s);return b.range(s.anchor,r.head,r.goalColumn,r.bidiLevel||void 0,r.assoc)});return i.eq(n.state.selection)?!1:(n.dispatch(Ge(n.state,i)),!0)}function yu(n,e){return ze(n,e,t=>n.moveByChar(t,e))}var bu=n=>yu(n,!pe(n)),xu=n=>yu(n,pe(n));function wu(n,e){return ze(n,e,t=>n.moveByGroup(t,e))}var P0=n=>wu(n,!pe(n)),I0=n=>wu(n,pe(n));var N0=n=>{let e=!pe(n);return ze(n,e,t=>qs(n.state,t,e))},F0=n=>{let e=pe(n);return ze(n,e,t=>qs(n.state,t,e))};function vu(n,e){return ze(n,e,t=>n.moveVertically(t,e))}var ku=n=>vu(n,!1),Su=n=>vu(n,!0);function Cu(n,e){return ze(n,e,t=>n.moveVertically(t,e,mu(n).height))}var Yf=n=>Cu(n,!1),Jf=n=>Cu(n,!0),H0=n=>ze(n,!0,e=>Mt(n,e,!0)),W0=n=>ze(n,!1,e=>Mt(n,e,!1)),V0=n=>{let e=!pe(n);return ze(n,e,t=>Mt(n,t,e))},z0=n=>{let e=pe(n);return ze(n,e,t=>Mt(n,t,e))},q0=n=>ze(n,!1,e=>b.cursor(n.lineBlockAt(e.head).from)),K0=n=>ze(n,!0,e=>b.cursor(n.lineBlockAt(e.head).to)),Xf=({state:n,dispatch:e})=>(e(Ge(n,{anchor:0})),!0),Qf=({state:n,dispatch:e})=>(e(Ge(n,{anchor:n.doc.length})),!0),Zf=({state:n,dispatch:e})=>(e(Ge(n,{anchor:n.selection.main.anchor,head:0})),!0),eu=({state:n,dispatch:e})=>(e(Ge(n,{anchor:n.selection.main.anchor,head:n.doc.length})),!0),$0=({state:n,dispatch:e})=>(e(n.update({selection:{anchor:0,head:n.doc.length},userEvent:"select"})),!0),j0=({state:n,dispatch:e})=>{let t=Ks(n).map(({from:i,to:s})=>b.range(i,Math.min(s+1,n.doc.length)));return e(n.update({selection:b.create(t),userEvent:"select"})),!0},U0=({state:n,dispatch:e})=>{let t=Ai(n.selection,i=>{let s=Z(n),r=s.resolveStack(i.from,1);if(i.empty){let o=s.resolveStack(i.from,-1);o.node.from>=r.node.from&&o.node.to<=r.node.to&&(r=o)}for(let o=r;o;o=o.next){let{node:l}=o;if((l.from=i.to||l.to>i.to&&l.from<=i.from)&&o.next)return b.range(l.to,l.from)}return i});return t.eq(n.selection)?!1:(e(Ge(n,t)),!0)};function Au(n,e){let{state:t}=n,i=t.selection,s=t.selection.ranges.slice();for(let r of t.selection.ranges){let o=t.doc.lineAt(r.head);if(e?o.to0)for(let l=r;;){let a=n.moveVertically(l,e);if(a.heado.to){s.some(h=>h.head==a.head)||s.push(a);break}else{if(a.head==l.head)break;l=a}}}return s.length==i.ranges.length?!1:(n.dispatch(Ge(t,b.create(s,s.length-1))),!0)}var G0=n=>Au(n,!1),_0=n=>Au(n,!0),Y0=({state:n,dispatch:e})=>{let t=n.selection,i=null;return t.ranges.length>1?i=b.create([t.main]):t.main.empty||(i=b.create([b.cursor(t.main.head)])),i?(e(Ge(n,i)),!0):!1};function mn(n,e){if(n.state.readOnly)return!1;let t="delete.selection",{state:i}=n,s=i.changeByRange(r=>{let{from:o,to:l}=r;if(o==l){let a=e(r);ao&&(t="delete.forward",a=Hs(n,a,!0)),o=Math.min(o,a),l=Math.max(l,a)}else o=Hs(n,o,!1),l=Hs(n,l,!0);return o==l?{range:r}:{changes:{from:o,to:l},range:b.cursor(o,os(n)))i.between(e,e,(s,r)=>{se&&(e=t?r:s)});return e}var Mu=(n,e,t)=>mn(n,i=>{let s=i.from,{state:r}=n,o=r.doc.lineAt(s),l,a;if(t&&!e&&s>o.from&&sMu(n,!1,!0);var Tu=n=>Mu(n,!0,!1),Du=(n,e)=>mn(n,t=>{let i=t.head,{state:s}=n,r=s.doc.lineAt(i),o=s.charCategorizer(i);for(let l=null;;){if(i==(e?r.to:r.from)){i==t.head&&r.number!=(e?s.doc.lines:1)&&(i+=e?1:-1);break}let a=te(r.text,i-r.from,e)+r.from,h=r.text.slice(Math.min(i,a)-r.from,Math.max(i,a)-r.from),c=o(h);if(l!=null&&c!=l)break;(h!=" "||i!=t.head)&&(l=c),i=a}return i}),Ou=n=>Du(n,!1),J0=n=>Du(n,!0);var X0=n=>mn(n,e=>{let t=n.lineBlockAt(e.head).to;return e.headmn(n,e=>{let t=n.moveToLineBoundary(e,!1).head;return e.head>t?t:Math.max(0,e.head-1)}),Z0=n=>mn(n,e=>{let t=n.moveToLineBoundary(e,!0).head;return e.head{if(n.readOnly)return!1;let t=n.changeByRange(i=>({changes:{from:i.from,to:i.to,insert:F.of(["",""])},range:b.cursor(i.from)}));return e(n.update(t,{scrollIntoView:!0,userEvent:"input"})),!0},ty=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=n.changeByRange(i=>{if(!i.empty||i.from==0||i.from==n.doc.length)return{range:i};let s=i.from,r=n.doc.lineAt(s),o=s==r.from?s-1:te(r.text,s-r.from,!1)+r.from,l=s==r.to?s+1:te(r.text,s-r.from,!0)+r.from;return{changes:{from:o,to:l,insert:n.doc.slice(s,l).append(n.doc.slice(o,s))},range:b.cursor(l)}});return t.changes.empty?!1:(e(n.update(t,{scrollIntoView:!0,userEvent:"move.character"})),!0)};function Ks(n){let e=[],t=-1;for(let i of n.selection.ranges){let s=n.doc.lineAt(i.from),r=n.doc.lineAt(i.to);if(!i.empty&&i.to==r.from&&(r=n.doc.lineAt(i.to-1)),t>=s.number){let o=e[e.length-1];o.to=r.to,o.ranges.push(i)}else e.push({from:s.from,to:r.to,ranges:[i]});t=r.number+1}return e}function Bu(n,e,t){if(n.readOnly)return!1;let i=[],s=[];for(let r of Ks(n)){if(t?r.to==n.doc.length:r.from==0)continue;let o=n.doc.lineAt(t?r.to+1:r.from-1),l=o.length+1;if(t){i.push({from:r.to,to:o.to},{from:r.from,insert:o.text+n.lineBreak});for(let a of r.ranges)s.push(b.range(Math.min(n.doc.length,a.anchor+l),Math.min(n.doc.length,a.head+l)))}else{i.push({from:o.from,to:r.from},{from:r.to,insert:n.lineBreak+o.text});for(let a of r.ranges)s.push(b.range(a.anchor-l,a.head-l))}}return i.length?(e(n.update({changes:i,scrollIntoView:!0,selection:b.create(s,n.selection.mainIndex),userEvent:"move.line"})),!0):!1}var iy=({state:n,dispatch:e})=>Bu(n,e,!1),ny=({state:n,dispatch:e})=>Bu(n,e,!0);function Lu(n,e,t){if(n.readOnly)return!1;let i=[];for(let r of Ks(n))t?i.push({from:r.from,insert:n.doc.slice(r.from,r.to)+n.lineBreak}):i.push({from:r.to,insert:n.lineBreak+n.doc.slice(r.from,r.to)});let s=n.changes(i);return e(n.update({changes:s,selection:n.selection.map(s,t?1:-1),scrollIntoView:!0,userEvent:"input.copyline"})),!0}var sy=({state:n,dispatch:e})=>Lu(n,e,!1),ry=({state:n,dispatch:e})=>Lu(n,e,!0),oy=n=>{if(n.state.readOnly)return!1;let{state:e}=n,t=e.changes(Ks(e).map(({from:s,to:r})=>(s>0?s--:r{let r;if(n.lineWrapping){let o=n.lineBlockAt(s.head),l=n.coordsAtPos(s.head,s.assoc||1);l&&(r=o.bottom+n.documentTop-l.bottom+n.defaultLineHeight/2)}return n.moveVertically(s,!0,r)}).map(t);return n.dispatch({changes:t,selection:i,scrollIntoView:!0,userEvent:"delete.line"}),!0};function ly(n,e){if(/\(\)|\[\]|\{\}/.test(n.sliceDoc(e-1,e+1)))return{from:e,to:e};let t=Z(n).resolveInner(e),i=t.childBefore(e),s=t.childAfter(e),r;return i&&s&&i.to<=e&&s.from>=e&&(r=i.type.prop(P.closedBy))&&r.indexOf(s.name)>-1&&n.doc.lineAt(i.to).from==n.doc.lineAt(s.from).from&&!/\S/.test(n.sliceDoc(i.to,s.from))?{from:i.to,to:s.from}:null}var tu=Eu(!1),ay=Eu(!0);function Eu(n){return({state:e,dispatch:t})=>{if(e.readOnly)return!1;let i=e.changeByRange(s=>{let{from:r,to:o}=s,l=e.doc.lineAt(r),a=!n&&r==o&&ly(e,r);n&&(r=o=(o<=l.to?l:e.doc.lineAt(o)).to);let h=new dt(e,{simulateBreak:r,simulateDoubleBreak:!!a}),c=Si(h,r);for(c==null&&(c=ot(/^\s*/.exec(e.doc.lineAt(r).text)[0],e.tabSize));ol.from&&r{let s=[];for(let o=i.from;o<=i.to;){let l=n.doc.lineAt(o);l.number>t&&(i.empty||i.to>l.from)&&(e(l,s,i),t=l.number),o=l.to+1}let r=n.changes(s);return{changes:s,range:b.range(r.mapPos(i.anchor,1),r.mapPos(i.head,1))}})}var hy=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=Object.create(null),i=new dt(n,{overrideIndentation:r=>{let o=t[r];return o??-1}}),s=_l(n,(r,o,l)=>{let a=Si(i,r.from);if(a==null)return;/\S/.test(r.text)||(a=0);let h=/^\s*/.exec(r.text)[0],c=At(n,a);(h!=c||l.fromn.readOnly?!1:(e(n.update(_l(n,(t,i)=>{i.push({from:t.from,insert:n.facet(_t)})}),{userEvent:"input.indent"})),!0),fy=({state:n,dispatch:e})=>n.readOnly?!1:(e(n.update(_l(n,(t,i)=>{let s=/^\s*/.exec(t.text)[0];if(!s)return;let r=ot(s,n.tabSize),o=0,l=At(n,Math.max(0,r-ut(n)));for(;o(n.setTabFocusMode(),!0);var dy=[{key:"Ctrl-b",run:hu,shift:bu,preventDefault:!0},{key:"Ctrl-f",run:cu,shift:xu},{key:"Ctrl-p",run:du,shift:ku},{key:"Ctrl-n",run:pu,shift:Su},{key:"Ctrl-a",run:B0,shift:q0},{key:"Ctrl-e",run:L0,shift:K0},{key:"Ctrl-d",run:Tu},{key:"Ctrl-h",run:Kl},{key:"Ctrl-k",run:X0},{key:"Ctrl-Alt-h",run:Ou},{key:"Ctrl-o",run:ey},{key:"Ctrl-t",run:ty},{key:"Ctrl-v",run:ql}],py=[{key:"ArrowLeft",run:hu,shift:bu,preventDefault:!0},{key:"Mod-ArrowLeft",mac:"Alt-ArrowLeft",run:v0,shift:P0,preventDefault:!0},{mac:"Cmd-ArrowLeft",run:D0,shift:V0,preventDefault:!0},{key:"ArrowRight",run:cu,shift:xu,preventDefault:!0},{key:"Mod-ArrowRight",mac:"Alt-ArrowRight",run:k0,shift:I0,preventDefault:!0},{mac:"Cmd-ArrowRight",run:O0,shift:z0,preventDefault:!0},{key:"ArrowUp",run:du,shift:ku,preventDefault:!0},{mac:"Cmd-ArrowUp",run:Xf,shift:Zf},{mac:"Ctrl-ArrowUp",run:_f,shift:Yf},{key:"ArrowDown",run:pu,shift:Su,preventDefault:!0},{mac:"Cmd-ArrowDown",run:Qf,shift:eu},{mac:"Ctrl-ArrowDown",run:ql,shift:Jf},{key:"PageUp",run:_f,shift:Yf},{key:"PageDown",run:ql,shift:Jf},{key:"Home",run:T0,shift:W0,preventDefault:!0},{key:"Mod-Home",run:Xf,shift:Zf},{key:"End",run:M0,shift:H0,preventDefault:!0},{key:"Mod-End",run:Qf,shift:eu},{key:"Enter",run:tu,shift:tu},{key:"Mod-a",run:$0},{key:"Backspace",run:Kl,shift:Kl,preventDefault:!0},{key:"Delete",run:Tu,preventDefault:!0},{key:"Mod-Backspace",mac:"Alt-Backspace",run:Ou,preventDefault:!0},{key:"Mod-Delete",mac:"Alt-Delete",run:J0,preventDefault:!0},{mac:"Mod-Backspace",run:Q0,preventDefault:!0},{mac:"Mod-Delete",run:Z0,preventDefault:!0}].concat(dy.map(n=>({mac:n.key,run:n.run,shift:n.shift}))),Yl=[{key:"Alt-ArrowLeft",mac:"Ctrl-ArrowLeft",run:C0,shift:N0},{key:"Alt-ArrowRight",mac:"Ctrl-ArrowRight",run:A0,shift:F0},{key:"Alt-ArrowUp",run:iy},{key:"Shift-Alt-ArrowUp",run:sy},{key:"Alt-ArrowDown",run:ny},{key:"Shift-Alt-ArrowDown",run:ry},{key:"Mod-Alt-ArrowUp",run:G0},{key:"Mod-Alt-ArrowDown",run:_0},{key:"Escape",run:Y0},{key:"Mod-Enter",run:ay},{key:"Alt-l",mac:"Ctrl-l",run:j0},{key:"Mod-i",run:U0,preventDefault:!0},{key:"Mod-[",run:fy},{key:"Mod-]",run:cy},{key:"Mod-Alt-\\",run:hy},{key:"Shift-Mod-k",run:oy},{key:"Shift-Mod-\\",run:R0},{key:"Mod-/",run:s0},{key:"Alt-A",run:o0},{key:"Ctrl-m",mac:"Shift-Alt-m",run:uy}].concat(py);var Ru=typeof String.prototype.normalize=="function"?n=>n.normalize("NFKD"):n=>n,Dt=class{constructor(e,t,i=0,s=e.length,r,o){this.test=o,this.value={from:0,to:0,precise:!1},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(i,s),this.bufferStart=i,this.normalize=r?l=>r(Ru(l)):Ru,this.query=this.normalize(t)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return he(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let t=Ii(e),i=this.bufferStart+this.bufferPos;this.bufferPos+=De(e);let s=this.normalize(t);if(s.length)for(let r=0,o=i,l=!0;;r++){let a=s.charCodeAt(r),h=this.match(a,o,l,this.bufferPos+this.bufferStart,r==s.length-1);if(h)return this.value=h,this;if(r==s.length-1)break;l&&rthis.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let t=this.matchPos<=this.to&&this.re.exec(this.curLine);if(t){let i=this.curLineStart+t.index,s=i+t[0].length;if(this.matchPos=Ys(this.text,s+(i==s?1:0)),i==this.curLineStart+this.curLine.length&&this.nextLine(),(ithis.value.to)&&(!this.test||this.test(i,s,t)))return this.value={from:i,to:s,precise:!0,match:t},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length=i||s.to<=t){let l=new n(t,e.sliceString(t,i));return Jl.set(e,l),l}if(s.from==t&&s.to==i)return s;let{text:r,from:o}=s;return o>t&&(r=e.sliceString(t,o)+r,o=t),s.to=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,t=this.re.exec(this.flat.text);if(t&&!t[0]&&t.index==e&&(this.re.lastIndex=e+1,t=this.re.exec(this.flat.text)),t){let i=this.flat.from+t.index,s=i+t[0].length;if((this.flat.to>=this.to||t.index+t[0].length<=this.flat.text.length-10)&&(!this.test||this.test(i,s,t)))return this.value={from:i,to:s,precise:!0,match:t},this.matchPos=Ys(this.text,s+(i==s?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=Gs.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+this.flat.text.length*2))}}};typeof Symbol<"u"&&(Us.prototype[Symbol.iterator]=_s.prototype[Symbol.iterator]=function(){return this});function my(n){try{return new RegExp(n,ta),!0}catch{return!1}}function Ys(n,e){if(e>=n.length)return e;let t=n.lineAt(e),i;for(;e=56320&&i<57344;)e++;return e}var gy=n=>{let{state:e}=n,t=String(e.doc.lineAt(n.state.selection.main.head).number),{close:i,result:s}=_c(n,{label:e.phrase("Go to line"),input:{type:"text",name:"line",value:t},focus:!0,submitLabel:e.phrase("go")});return s.then(r=>{let o=r&&/^([+-])?(\d+)?(:\d+)?(%)?$/.exec(r.elements.line.value);if(!o){n.dispatch({effects:i});return}let l=e.doc.lineAt(e.selection.main.head),[,a,h,c,f]=o,u=c?+c.slice(1):0,d=h?+h:l.number;if(h&&f){let g=d/100;a&&(g=g*(a=="-"?-1:1)+l.number/e.doc.lines),d=Math.round(e.doc.lines*g)}else h&&a&&(d=d*(a=="-"?-1:1)+l.number);let p=e.doc.line(Math.max(1,Math.min(e.doc.lines,d))),m=b.cursor(p.from+Math.max(0,Math.min(u,p.length)));n.dispatch({effects:[i,A.scrollIntoView(m.from,{y:"center"})],selection:m})}),!0},yy={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},Fu=C.define({combine(n){return ce(n,yy,{highlightWordAroundCursor:(e,t)=>e||t,minSelectionLength:Math.min,maxMatches:Math.min})}});function Hu(n){let e=[ky,vy];return n&&e.push(Fu.of(n)),e}var by=B.mark({class:"cm-selectionMatch"}),xy=B.mark({class:"cm-selectionMatch cm-selectionMatch-main"});function Pu(n,e,t,i){return(t==0||n(e.sliceDoc(t-1,t))!=K.Word)&&(i==e.doc.length||n(e.sliceDoc(i,i+1))!=K.Word)}function wy(n,e,t,i){return n(e.sliceDoc(t,t+1))==K.Word&&n(e.sliceDoc(i-1,i))==K.Word}var vy=Y.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.selectionSet||n.docChanged||n.viewportChanged)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=n.state.facet(Fu),{state:t}=n,i=t.selection;if(i.ranges.length>1)return B.none;let s=i.main,r,o=null;if(s.empty){if(!e.highlightWordAroundCursor)return B.none;let a=t.wordAt(s.head);if(!a)return B.none;o=t.charCategorizer(s.head),r=t.sliceDoc(a.from,a.to)}else{let a=s.to-s.from;if(a200)return B.none;if(e.wholeWords){if(r=t.sliceDoc(s.from,s.to),o=t.charCategorizer(s.head),!(Pu(o,t,s.from,s.to)&&wy(o,t,s.from,s.to)))return B.none}else if(r=t.sliceDoc(s.from,s.to),!r)return B.none}let l=[];for(let a of n.visibleRanges){let h=new Dt(t.doc,r,a.from,a.to);for(;!h.next().done;){let{from:c,to:f}=h.value;if((!o||Pu(o,t,c,f))&&(s.empty&&c<=s.from&&f>=s.to?l.push(xy.range(c,f)):(c>=s.to||f<=s.from)&&l.push(by.range(c,f)),l.length>e.maxMatches))return B.none}}return B.set(l)}},{decorations:n=>n.decorations}),ky=A.baseTheme({".cm-selectionMatch":{backgroundColor:"#99ff7780"},".cm-searchMatch .cm-selectionMatch":{backgroundColor:"transparent"}}),Sy=({state:n,dispatch:e})=>{let{selection:t}=n,i=b.create(t.ranges.map(s=>n.wordAt(s.head)||b.cursor(s.head)),t.mainIndex);return i.eq(t)?!1:(e(n.update({selection:i})),!0)};function Cy(n,e){let{main:t,ranges:i}=n.selection,s=n.wordAt(t.head),r=s&&s.from==t.from&&s.to==t.to;for(let o=!1,l=new Dt(n.doc,e,i[i.length-1].to);;)if(l.next(),l.done){if(o)return null;l=new Dt(n.doc,e,0,Math.max(0,i[i.length-1].from-1)),o=!0}else{if(o&&i.some(a=>a.from==l.value.from))continue;if(r){let a=n.wordAt(l.value.from);if(!a||a.from!=l.value.from||a.to!=l.value.to)continue}return l.value}}var Ay=({state:n,dispatch:e})=>{let{ranges:t}=n.selection;if(t.some(r=>r.from===r.to))return Sy({state:n,dispatch:e});let i=n.sliceDoc(t[0].from,t[0].to);if(n.selection.ranges.some(r=>n.sliceDoc(r.from,r.to)!=i))return!1;let s=Cy(n,i);return s?(e(n.update({selection:n.selection.addRange(b.range(s.from,s.to),!1),effects:A.scrollIntoView(s.to)})),!0):!1},Di=C.define({combine(n){return ce(n,{top:!1,caseSensitive:!1,literal:!1,regexp:!1,wholeWord:!1,createPanel:e=>new ea(e),scrollToMatch:e=>A.scrollIntoView(e)})}});var Js=class{constructor(e){this.search=e.search,this.caseSensitive=!!e.caseSensitive,this.literal=!!e.literal,this.regexp=!!e.regexp,this.replace=e.replace||"",this.valid=!!this.search&&(!this.regexp||my(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!e.wholeWord,this.test=e.test}unquote(e){return this.literal?e:e.replace(/\\([nrt\\])/g,(t,i)=>i=="n"?` +`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord&&this.test==e.test}create(){return this.regexp?new Ql(this):new Xl(this)}getCursor(e,t=0,i){let s=e.doc?e:j.create({doc:e});return i==null&&(i=s.doc.length),this.regexp?Ti(this,s,t,i):Mi(this,s,t,i)}},Xs=class{constructor(e){this.spec=e}};function My(n,e,t){return(i,s,r,o)=>{if(t&&!t(i,s,r,o))return!1;let l=i>=o&&s<=o+r.length?r.slice(i-o,s-o):e.doc.sliceString(i,s);return n(l,e,i,s)}}function Mi(n,e,t,i){let s;return n.wholeWord&&(s=Ty(e.doc,e.charCategorizer(e.selection.main.head))),n.test&&(s=My(n.test,e,s)),new Dt(e.doc,n.unquoted,t,i,n.caseSensitive?void 0:r=>r.toLowerCase(),s)}function Ty(n,e){return(t,i,s,r)=>((r>t||r+s.length=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Mi(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.doc.length));for(;!r.next().done;)s(r.value.from,r.value.to)}};function Dy(n,e,t){return(i,s,r)=>(!t||t(i,s,r))&&n(r[0],e,i,s)}function Ti(n,e,t,i){let s;return n.wholeWord&&(s=Oy(e.charCategorizer(e.selection.main.head))),n.test&&(s=Dy(n.test,e,s)),new Us(e.doc,n.search,{ignoreCase:!n.caseSensitive,test:s},t,i)}function Qs(n,e){return n.slice(te(n,e,!1),e)}function Zs(n,e){return n.slice(e,te(n,e))}function Oy(n){return(e,t,i)=>!i[0].length||(n(Qs(i.input,i.index))!=K.Word||n(Zs(i.input,i.index))!=K.Word)&&(n(Zs(i.input,i.index+i[0].length))!=K.Word||n(Qs(i.input,i.index+i[0].length))!=K.Word)}var Ql=class extends Xs{nextMatch(e,t,i){let s=Ti(this.spec,e,i,e.doc.length).next();return s.done&&(s=Ti(this.spec,e,0,t).next()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=1;;s++){let r=Math.max(t,i-s*1e4),o=Ti(this.spec,e,r,i),l=null;for(;!o.next().done;)l=o.value;if(l&&(r==t||l.from>r+10))return l;if(r==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g,(t,i)=>{if(i=="&")return e.match[0];if(i=="$")return"$";for(let s=i.length;s>0;s--){let r=+i.slice(0,s);if(r>0&&r=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Ti(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.doc.length));for(;!r.next().done;)s(r.value.from,r.value.to)}},yn=L.define(),ia=L.define(),Tt=J.define({create(n){return new gn(Zl(n).create(),null)},update(n,e){for(let t of e.effects)t.is(yn)?n=new gn(t.value.create(),n.panel):t.is(ia)&&(n=new gn(n.query,t.value?na:null));return n},provide:n=>qt.from(n,e=>e.panel)});var gn=class{constructor(e,t){this.query=e,this.panel=t}},By=B.mark({class:"cm-searchMatch"}),Ly=B.mark({class:"cm-searchMatch cm-searchMatch-selected"}),Ey=Y.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(Tt))}update(n){let e=n.state.field(Tt);(e!=n.startState.field(Tt)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return B.none;let{view:t}=this,i=new Se;for(let s=0,r=t.visibleRanges,o=r.length;sr[s+1].from-500;)a=r[++s].to;n.highlight(t.state,l,a,(h,c)=>{let f=t.state.selection.ranges.some(u=>u.from==h&&u.to==c);i.add(h,c,f?Ly:By)})}return i.finish()}},{decorations:n=>n.decorations});function bn(n){return e=>{let t=e.state.field(Tt,!1);return t&&t.query.spec.valid?n(e,t):zu(e)}}var er=bn((n,{query:e})=>{let{to:t}=n.state.selection.main,i=e.nextMatch(n.state,t,t);if(!i)return!1;let s=b.single(i.from,i.to),r=n.state.facet(Di);return n.dispatch({selection:s,effects:[sa(n,i),r.scrollToMatch(s.main,n)],userEvent:"select.search"}),Vu(n),!0}),tr=bn((n,{query:e})=>{let{state:t}=n,{from:i}=t.selection.main,s=e.prevMatch(t,i,i);if(!s)return!1;let r=b.single(s.from,s.to),o=n.state.facet(Di);return n.dispatch({selection:r,effects:[sa(n,s),o.scrollToMatch(r.main,n)],userEvent:"select.search"}),Vu(n),!0}),Ry=bn((n,{query:e})=>{let t=e.matchAll(n.state,1e3);return!t||!t.length?!1:(n.dispatch({selection:b.create(t.map(i=>b.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),Py=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:s}=t.main,r=[],o=0;for(let l=new Dt(n.doc,n.sliceDoc(i,s));!l.next().done;){if(r.length>1e3)return!1;l.value.from==i&&(o=r.length),r.push(b.range(l.value.from,l.value.to))}return e(n.update({selection:b.create(r,o),userEvent:"select.search.matches"})),!0},Iu=bn((n,{query:e})=>{let{state:t}=n,{from:i,to:s}=t.selection.main;if(t.readOnly)return!1;let r=e.nextMatch(t,i,i);if(!r)return!1;let o=r,l=[],a,h,c=[];o.precise?o.from==i&&o.to==s&&(h=t.toText(e.getReplacement(o)),l.push({from:o.from,to:o.to,insert:h}),o=e.nextMatch(t,o.from,o.to),c.push(A.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+"."))):o=e.nextMatch(t,o.from,o.to);let f=n.state.changes(l);return o&&(a=b.single(o.from,o.to).map(f),c.push(sa(n,o)),c.push(t.facet(Di).scrollToMatch(a.main,n))),n.dispatch({changes:f,selection:a,effects:c,userEvent:"input.replace"}),!0}),Iy=bn((n,{query:e})=>{if(n.state.readOnly)return!1;let t=[];for(let s of e.matchAll(n.state,1e9)){let{from:r,to:o,precise:l}=s;l&&t.push({from:r,to:o,insert:e.getReplacement(s)})}if(!t.length)return!1;let i=n.state.phrase("replaced $ matches",t.length)+".";return n.dispatch({changes:t,effects:A.announce.of(i),userEvent:"input.replace.all"}),!0});function na(n){return n.state.facet(Di).createPanel(n)}function Zl(n,e){var t,i,s,r,o;let l=n.selection.main,a=l.empty||l.to>l.from+100?"":n.sliceDoc(l.from,l.to);if(e&&!a)return e;let h=n.facet(Di);return new Js({search:((t=e?.literal)!==null&&t!==void 0?t:h.literal)?a:a.replace(/\n/g,"\\n"),caseSensitive:(i=e?.caseSensitive)!==null&&i!==void 0?i:h.caseSensitive,literal:(s=e?.literal)!==null&&s!==void 0?s:h.literal,regexp:(r=e?.regexp)!==null&&r!==void 0?r:h.regexp,wholeWord:(o=e?.wholeWord)!==null&&o!==void 0?o:h.wholeWord})}function Wu(n){let e=Zi(n,na);return e&&e.dom.querySelector("[main-field]")}function Vu(n){let e=Wu(n);e&&e==n.root.activeElement&&e.select()}var zu=n=>{let e=n.state.field(Tt,!1);if(e&&e.panel){let t=Wu(n);if(t&&t!=n.root.activeElement){let i=Zl(n.state,e.query.spec);i.valid&&n.dispatch({effects:yn.of(i)}),t.focus(),t.select()}}else n.dispatch({effects:[ia.of(!0),e?yn.of(Zl(n.state,e.query.spec)):L.appendConfig.of(Fy)]});return!0},qu=n=>{let e=n.state.field(Tt,!1);if(!e||!e.panel)return!1;let t=Zi(n,na);return t&&t.dom.contains(n.root.activeElement)&&n.focus(),n.dispatch({effects:ia.of(!1)}),!0},Ku=[{key:"Mod-f",run:zu,scope:"editor search-panel"},{key:"F3",run:er,shift:tr,scope:"editor search-panel",preventDefault:!0},{key:"Mod-g",run:er,shift:tr,scope:"editor search-panel",preventDefault:!0},{key:"Escape",run:qu,scope:"editor search-panel"},{key:"Mod-Shift-l",run:Py},{key:"Mod-Alt-g",run:gy},{key:"Mod-d",run:Ay,preventDefault:!0}],ea=class{constructor(e){this.view=e;let t=this.query=e.state.field(Tt).query.spec;this.commit=this.commit.bind(this),this.searchField=W("input",{value:t.search,placeholder:Le(e,"Find"),"aria-label":Le(e,"Find"),class:"cm-textfield",name:"search",form:"","main-field":"true",onchange:this.commit,onkeyup:this.commit}),this.replaceField=W("input",{value:t.replace,placeholder:Le(e,"Replace"),"aria-label":Le(e,"Replace"),class:"cm-textfield",name:"replace",form:"",onchange:this.commit,onkeyup:this.commit}),this.caseField=W("input",{type:"checkbox",name:"case",form:"",checked:t.caseSensitive,onchange:this.commit}),this.reField=W("input",{type:"checkbox",name:"re",form:"",checked:t.regexp,onchange:this.commit}),this.wordField=W("input",{type:"checkbox",name:"word",form:"",checked:t.wholeWord,onchange:this.commit});function i(s,r,o){return W("button",{class:"cm-button",name:s,onclick:r,type:"button"},o)}this.dom=W("div",{onkeydown:s=>this.keydown(s),class:"cm-search"},[this.searchField,i("next",()=>er(e),[Le(e,"next")]),i("prev",()=>tr(e),[Le(e,"previous")]),i("select",()=>Ry(e),[Le(e,"all")]),W("label",null,[this.caseField,Le(e,"match case")]),W("label",null,[this.reField,Le(e,"regexp")]),W("label",null,[this.wordField,Le(e,"by word")]),...e.state.readOnly?[]:[W("br"),this.replaceField,i("replace",()=>Iu(e),[Le(e,"replace")]),i("replaceAll",()=>Iy(e),[Le(e,"replace all")])],W("button",{name:"close",onclick:()=>qu(e),"aria-label":Le(e,"close"),type:"button"},["\xD7"])])}commit(){let e=new Js({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});e.eq(this.query)||(this.query=e,this.view.dispatch({effects:yn.of(e)}))}keydown(e){Ic(this.view,e,"search-panel")?e.preventDefault():e.keyCode==13&&e.target==this.searchField?(e.preventDefault(),(e.shiftKey?tr:er)(this.view)):e.keyCode==13&&e.target==this.replaceField&&(e.preventDefault(),Iu(this.view))}update(e){for(let t of e.transactions)for(let i of t.effects)i.is(yn)&&!i.value.eq(this.query)&&this.setQuery(i.value)}setQuery(e){this.query=e,this.searchField.value=e.search,this.replaceField.value=e.replace,this.caseField.checked=e.caseSensitive,this.reField.checked=e.regexp,this.wordField.checked=e.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(Di).top}};function Le(n,e){return n.state.phrase(e)}var $s=30,js=/[\s\.,:;?!]/;function sa(n,{from:e,to:t}){let i=n.state.doc.lineAt(e),s=n.state.doc.lineAt(t).to,r=Math.max(i.from,e-$s),o=Math.min(s,t+$s),l=n.state.sliceDoc(r,o);if(r!=i.from){for(let a=0;a<$s;a++)if(!js.test(l[a+1])&&js.test(l[a])){l=l.slice(a);break}}if(o!=s){for(let a=l.length-1;a>l.length-$s;a--)if(!js.test(l[a-1])&&js.test(l[a])){l=l.slice(0,a);break}}return A.announce.of(`${n.state.phrase("current match")}. ${l} ${n.state.phrase("on line")} ${i.number}.`)}var Ny=A.baseTheme({".cm-panel.cm-search":{padding:"2px 6px 4px",position:"relative","& [name=close]":{position:"absolute",top:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:0,margin:0},"& input, & button, & label":{margin:".2em .6em .2em 0"},"& input[type=checkbox]":{marginRight:".2em"},"& label":{fontSize:"80%",whiteSpace:"pre"}},"&light .cm-searchMatch":{backgroundColor:"#ffff0054"},"&dark .cm-searchMatch":{backgroundColor:"#00ffff8a"},"&light .cm-searchMatch-selected":{backgroundColor:"#ff6a0054"},"&dark .cm-searchMatch-selected":{backgroundColor:"#ff00ff8a"}}),Fy=[Tt,Oe.low(Ey),Ny];var wd={};Tn(wd,{CompletionContext:()=>wn,acceptCompletion:()=>rd,autocompletion:()=>Sa,clearSnippet:()=>cd,closeBrackets:()=>va,closeBracketsKeymap:()=>ka,closeCompletion:()=>od,completeAnyWord:()=>mb,completeFromList:()=>td,completionKeymap:()=>hr,completionStatus:()=>Cb,currentCompletions:()=>Ab,deleteBracketPair:()=>yd,hasNextSnippetField:()=>lb,hasPrevSnippetField:()=>ab,ifIn:()=>Wy,ifNotIn:()=>Vy,insertBracket:()=>bd,insertCompletionText:()=>nd,moveCompletionSelection:()=>xn,nextSnippetField:()=>fd,pickedCompletion:()=>or,prevSnippetField:()=>ud,selectedCompletion:()=>Mb,selectedCompletionIndex:()=>Tb,setSelectedCompletion:()=>Db,snippet:()=>ad,snippetCompletion:()=>fb,snippetKeymap:()=>ga,startCompletion:()=>ir});var wn=class{constructor(e,t,i,s){this.state=e,this.pos=t,this.explicit=i,this.view=s,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=Z(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),i=Math.max(t.from,this.pos-250),s=t.text.slice(i-t.from,this.pos-t.from),r=s.search(id(e,!1));return r<0?null:{from:i+r,to:this.pos,text:s.slice(r)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,i){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),i&&i.onDocChange&&(this.abortOnDocChange=!0))}};function $u(n){let e=Object.keys(n).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function Hy(n){let e=Object.create(null),t=Object.create(null);for(let{label:s}of n){e[s[0]]=!0;for(let r=1;rtypeof s=="string"?{label:s}:s),[t,i]=e.every(s=>/^\w+$/.test(s.label))?[/\w*$/,/\w+$/]:Hy(e);return s=>{let r=s.matchBefore(i);return r||s.explicit?{from:r?r.from:s.pos,options:e,validFor:t}:null}}function Wy(n,e){return t=>{for(let i=Z(t.state).resolveInner(t.pos,-1);i;i=i.parent){if(n.indexOf(i.name)>-1)return e(t);if(i.type.isTop)break}return null}}function Vy(n,e){return t=>{for(let i=Z(t.state).resolveInner(t.pos,-1);i;i=i.parent){if(n.indexOf(i.name)>-1)return null;if(i.type.isTop)break}return e(t)}}var nr=class{constructor(e,t,i,s){this.completion=e,this.source=t,this.match=i,this.score=s}};function Qt(n){return n.selection.main.from}function id(n,e){var t;let{source:i}=n,s=e&&i[0]!="^",r=i[i.length-1]!="$";return!s&&!r?n:new RegExp(`${s?"^":""}(?:${i})${r?"$":""}`,(t=n.flags)!==null&&t!==void 0?t:n.ignoreCase?"i":"")}var or=ke.define();function nd(n,e,t,i){let{main:s}=n.selection,r=t-s.from,o=i-s.from;return{...n.changeByRange(l=>{if(l!=s&&t!=i&&n.sliceDoc(l.from+r,l.from+o)!=n.sliceDoc(t,i))return{range:l};let a=n.toText(e);return{changes:{from:l.from+r,to:i==s.from?l.to:l.from+o,insert:a},range:b.cursor(l.from+r+a.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}var ju=new WeakMap;function zy(n){if(!Array.isArray(n))return n;let e=ju.get(n);return e||ju.set(n,e=td(n)),e}var sr=L.define(),vn=L.define(),la=class{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t=48&&v<=57||v>=97&&v<=122?2:v>=65&&v<=90?1:0:(k=Ii(v))!=k.toLowerCase()?1:k!=k.toUpperCase()?2:0;(!x||S==1&&g||O==0&&S!=0)&&(t[f]==v||i[f]==v&&(u=!0)?o[f++]=x:o.length&&(y=!1)),O=S,x+=De(v)}return f==a&&o[0]==0&&y?this.result(-100+(u?-200:0),o,e):d==a&&p==0?this.ret(-200-e.length+(m==e.length?0:-100),[0,m]):l>-1?this.ret(-700-e.length,[l,l+this.pattern.length]):d==a?this.ret(-900-e.length,[p,m]):f==a?this.result(-100+(u?-200:0)+-700+(y?0:-1100),o,e):t.length==2?null:this.result((s[0]?-700:0)+-200+-1100,s,e)}result(e,t,i){let s=[],r=0;for(let o of t){let l=o+(this.astral?De(he(i,o)):1);r&&s[r-1]==o?s[r-1]=l:(s[r++]=o,s[r++]=l)}return this.ret(e-i.length,s)}},aa=class{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length!1,activateOnTypingDelay:100,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,tooltipClass:()=>"",optionClass:()=>"",aboveCursor:!1,icons:!0,addToOptions:[],positionInfo:qy,filterStrict:!1,compareCompletions:(e,t)=>(e.sortText||e.label).localeCompare(t.sortText||t.label),interactionDelay:75,updateSyncTime:100},{defaultKeymap:(e,t)=>e&&t,closeOnBlur:(e,t)=>e&&t,icons:(e,t)=>e&&t,tooltipClass:(e,t)=>i=>Uu(e(i),t(i)),optionClass:(e,t)=>i=>Uu(e(i),t(i)),addToOptions:(e,t)=>e.concat(t),filterStrict:(e,t)=>e||t})}});function Uu(n,e){return n?e?n+" "+e:n:e}function qy(n,e,t,i,s,r){let o=n.textDirection==V.RTL,l=o,a=!1,h="top",c,f,u=e.left-s.left,d=s.right-e.right,p=i.right-i.left,m=i.bottom-i.top;if(l&&u=m||x>e.top?c=t.bottom-e.top:(h="bottom",c=e.bottom-t.top)}let g=(e.bottom-e.top)/r.offsetHeight,y=(e.right-e.left)/r.offsetWidth;return{style:`${h}: ${c/g}px; max-width: ${f/y}px`,class:"cm-completionInfo-"+(a?o?"left-narrow":"right-narrow":l?"left":"right")}}var lr=L.define();function Ky(n){let e=n.addToOptions.slice();return n.icons&&e.push({render(t){let i=document.createElement("div");return i.classList.add("cm-completionIcon"),t.type&&i.classList.add(...t.type.split(/\s+/g).map(s=>"cm-completionIcon-"+s)),i.setAttribute("aria-hidden","true"),i},position:20}),e.push({render(t,i,s,r){let o=document.createElement("span");o.className="cm-completionLabel";let l=t.displayLabel||t.label,a=0;for(let h=0;ha&&o.appendChild(document.createTextNode(l.slice(a,c)));let u=o.appendChild(document.createElement("span"));u.appendChild(document.createTextNode(l.slice(c,f))),u.className="cm-completionMatchedText",a=f}return at.position-i.position).map(t=>t.render)}function ra(n,e,t){if(n<=t)return{from:0,to:n};if(e<0&&(e=0),e<=n>>1){let s=Math.floor(e/t);return{from:s*t,to:(s+1)*t}}let i=Math.ceil((n-e)/t);return{from:n-i*t,to:n-(i-1)*t}}var ha=class{constructor(e,t,i){this.view=e,this.stateField=t,this.applyCompletion=i,this.info=null,this.infoDestroy=null,this.placeInfoReq={read:()=>this.measureInfo(),write:a=>this.placeInfo(a),key:this},this.space=null,this.currentClass="";let s=e.state.field(t),{options:r,selected:o}=s.open,l=e.state.facet(le);this.optionContent=Ky(l),this.optionClass=l.optionClass,this.tooltipClass=l.tooltipClass,this.range=ra(r.length,o,l.maxRenderedOptions),this.dom=document.createElement("div"),this.dom.className="cm-tooltip-autocomplete",this.updateTooltipClass(e.state),this.dom.addEventListener("mousedown",a=>{let{options:h}=e.state.field(t).open;for(let c=a.target,f;c&&c!=this.dom;c=c.parentNode)if(c.nodeName=="LI"&&(f=/-(\d+)$/.exec(c.id))&&+f[1]this.list.lastChild.getBoundingClientRect().bottom?this.range.to:null;c!=null&&(e.dispatch({effects:lr.of(c)}),a.preventDefault())}}),this.dom.addEventListener("focusout",a=>{let h=e.state.field(this.stateField,!1);h&&h.tooltip&&e.state.facet(le).closeOnBlur&&a.relatedTarget!=e.contentDOM&&e.dispatch({effects:vn.of(null)})}),this.showOptions(r,s.id)}mount(){this.updateSel()}showOptions(e,t){this.list&&this.list.remove(),this.list=this.dom.appendChild(this.createListBox(e,t,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})}update(e){var t;let i=e.state.field(this.stateField),s=e.startState.field(this.stateField);if(this.updateTooltipClass(e.state),i!=s){let{options:r,selected:o,disabled:l}=i.open;(!s.open||s.open.options!=r)&&(this.range=ra(r.length,o,e.state.facet(le).maxRenderedOptions),this.showOptions(r,i.id)),this.updateSel(),l!=((t=s.open)===null||t===void 0?void 0:t.disabled)&&this.dom.classList.toggle("cm-tooltip-autocomplete-disabled",!!l)}}updateTooltipClass(e){let t=this.tooltipClass(e);if(t!=this.currentClass){for(let i of this.currentClass.split(" "))i&&this.dom.classList.remove(i);for(let i of t.split(" "))i&&this.dom.classList.add(i);this.currentClass=t}}positioned(e){this.space=e,this.info&&this.view.requestMeasure(this.placeInfoReq)}updateSel(){let e=this.view.state.field(this.stateField),t=e.open;(t.selected>-1&&t.selected=this.range.to)&&(this.range=ra(t.options.length,t.selected,this.view.state.facet(le).maxRenderedOptions),this.showOptions(t.options,e.id));let i=this.updateSelectedOption(t.selected);if(i){this.destroyInfo();let{completion:s}=t.options[t.selected],{info:r}=s;if(!r)return;let o=typeof r=="string"?document.createTextNode(r):r(s);if(!o)return;"then"in o?o.then(l=>{l&&this.view.state.field(this.stateField,!1)==e&&this.addInfoPane(l,s)}).catch(l=>re(this.view.state,l,"completion info")):(this.addInfoPane(o,s),i.setAttribute("aria-describedby",this.info.id))}}addInfoPane(e,t){this.destroyInfo();let i=this.info=document.createElement("div");if(i.className="cm-tooltip cm-completionInfo",i.id="cm-completionInfo-"+Math.floor(Math.random()*65535).toString(16),e.nodeType!=null)i.appendChild(e),this.infoDestroy=null;else{let{dom:s,destroy:r}=e;i.appendChild(s),this.infoDestroy=r||null}this.dom.appendChild(i),this.view.requestMeasure(this.placeInfoReq)}updateSelectedOption(e){let t=null;for(let i=this.list.firstChild,s=this.range.from;i;i=i.nextSibling,s++)i.nodeName!="LI"||!i.id?s--:s==e?i.hasAttribute("aria-selected")||(i.setAttribute("aria-selected","true"),t=i):i.hasAttribute("aria-selected")&&(i.removeAttribute("aria-selected"),i.removeAttribute("aria-describedby"));return t&&jy(this.list,t),t}measureInfo(){let e=this.dom.querySelector("[aria-selected]");if(!e||!this.info)return null;let t=this.dom.getBoundingClientRect(),i=this.info.getBoundingClientRect(),s=e.getBoundingClientRect(),r=this.space;if(!r){let o=this.dom.ownerDocument.documentElement;r={left:0,top:0,right:o.clientWidth,bottom:o.clientHeight}}return s.top>Math.min(r.bottom,t.bottom)-10||s.bottom{o.target==s&&o.preventDefault()});let r=null;for(let o=i.from;oi.from||i.from==0))if(r=u,typeof h!="string"&&h.header)s.appendChild(h.header(h));else{let d=s.appendChild(document.createElement("completion-section"));d.textContent=u}}let c=s.appendChild(document.createElement("li"));c.id=t+"-"+o,c.setAttribute("role","option");let f=this.optionClass(l);f&&(c.className=f);for(let u of this.optionContent){let d=u(l,this.view.state,this.view,a);d&&c.appendChild(d)}}return i.from&&s.classList.add("cm-completionListIncompleteTop"),i.tonew ha(t,n,e)}function jy(n,e){let t=n.getBoundingClientRect(),i=e.getBoundingClientRect(),s=t.height/n.offsetHeight;i.topt.bottom&&(n.scrollTop+=(i.bottom-t.bottom)/s)}function Gu(n){return(n.boost||0)*100+(n.apply?10:0)+(n.info?5:0)+(n.type?1:0)}function Uy(n,e){let t=[],i=null,s=null,r=c=>{t.push(c);let{section:f}=c.completion;if(f){i||(i=[]);let u=typeof f=="string"?f:f.name;i.some(d=>d.name==u)||i.push(typeof f=="string"?{name:u}:f)}},o=e.facet(le);for(let c of n)if(c.hasResult()){let f=c.result.getMatch;if(c.result.filter===!1)for(let u of c.result.options)r(new nr(u,c.source,f?f(u):[],1e9-t.length));else{let u=e.sliceDoc(c.from,c.to),d,p=o.filterStrict?new aa(u):new la(u);for(let m of c.result.options)if(d=p.match(m.label)){let g=m.displayLabel?f?f(m,d.matched):[]:d.matched,y=d.score+(m.boost||0);if(r(new nr(m,c.source,g,y)),typeof m.section=="object"&&m.section.rank==="dynamic"){let{name:x}=m.section;s||(s=Object.create(null)),s[x]=Math.max(y,s[x]||-1e9)}}}}if(i){let c=Object.create(null),f=0,u=(d,p)=>(d.rank==="dynamic"&&p.rank==="dynamic"?s[p.name]-s[d.name]:0)||(typeof d.rank=="number"?d.rank:1e9)-(typeof p.rank=="number"?p.rank:1e9)||(d.nameu.score-f.score||h(f.completion,u.completion))){let f=c.completion;!a||a.label!=f.label||a.detail!=f.detail||a.type!=null&&f.type!=null&&a.type!=f.type||a.apply!=f.apply||a.boost!=f.boost?l.push(c):Gu(c.completion)>Gu(a)&&(l[l.length-1]=c),a=c.completion}return l}var ca=class n{constructor(e,t,i,s,r,o){this.options=e,this.attrs=t,this.tooltip=i,this.timestamp=s,this.selected=r,this.disabled=o}setSelected(e,t){return e==this.selected||e>=this.options.length?this:new n(this.options,_u(t,e),this.tooltip,this.timestamp,e,this.disabled)}static build(e,t,i,s,r,o){if(s&&!o&&e.some(h=>h.isPending))return s.setDisabled();let l=Uy(e,t);if(!l.length)return s&&e.some(h=>h.isPending)?s.setDisabled():null;let a=t.facet(le).selectOnOpen?0:-1;if(s&&s.selected!=a&&s.selected!=-1){let h=s.options[s.selected].completion;for(let c=0;cc.hasResult()?Math.min(h,c.from):h,1e8),create:Qy,above:r.aboveCursor},s?s.timestamp:Date.now(),a,!1)}map(e){return new n(this.options,this.attrs,{...this.tooltip,pos:e.mapPos(this.tooltip.pos)},this.timestamp,this.selected,this.disabled)}setDisabled(){return new n(this.options,this.attrs,this.tooltip,this.timestamp,this.selected,!0)}},fa=class n{constructor(e,t,i){this.active=e,this.id=t,this.open=i}static start(){return new n(Jy,"cm-ac-"+Math.floor(Math.random()*2e6).toString(36),null)}update(e){let{state:t}=e,i=t.facet(le),r=(i.override||t.languageDataAt("autocomplete",Qt(t)).map(zy)).map(a=>(this.active.find(c=>c.source==a)||new mt(a,this.active.some(c=>c.state!=0)?1:0)).update(e,i));r.length==this.active.length&&r.every((a,h)=>a==this.active[h])&&(r=this.active);let o=this.open,l=e.effects.some(a=>a.is(ya));o&&e.docChanged&&(o=o.map(e.changes)),e.selection||r.some(a=>a.hasResult()&&e.changes.touchesRange(a.from,a.to))||!Gy(r,this.active)||l?o=ca.build(r,t,this.id,o,i,l):o&&o.disabled&&!r.some(a=>a.isPending)&&(o=null),!o&&r.every(a=>!a.isPending)&&r.some(a=>a.hasResult())&&(r=r.map(a=>a.hasResult()?new mt(a.source,0):a));for(let a of e.effects)a.is(lr)&&(o=o&&o.setSelected(a.value,this.id));return r==this.active&&o==this.open?this:new n(r,this.id,o)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:this.active.length?_y:Yy}};function Gy(n,e){if(n==e)return!0;for(let t=0,i=0;;){for(;t-1&&(t["aria-activedescendant"]=n+"-"+e),t}var Jy=[];function sd(n,e){if(n.isUserEvent("input.complete")){let i=n.annotation(or);if(i&&e.activateOnCompletion(i))return 12}let t=n.isUserEvent("input.type");return t&&e.activateOnTyping?5:t?1:n.isUserEvent("delete.backward")?2:n.selection?8:n.docChanged?16:0}var mt=class n{constructor(e,t,i=!1){this.source=e,this.state=t,this.explicit=i}hasResult(){return!1}get isPending(){return this.state==1}update(e,t){let i=sd(e,t),s=this;(i&8||i&16&&this.touches(e))&&(s=new n(s.source,0)),i&4&&s.state==0&&(s=new n(this.source,1)),s=s.updateFor(e,i);for(let r of e.effects)if(r.is(sr))s=new n(s.source,1,r.value);else if(r.is(vn))s=new n(s.source,0);else if(r.is(ya))for(let o of r.value)o.source==s.source&&(s=o);return s}updateFor(e,t){return this.map(e.changes)}map(e){return this}touches(e){return e.changes.touchesRange(Qt(e.state))}},rr=class n extends mt{constructor(e,t,i,s,r,o){super(e,3,t),this.limit=i,this.result=s,this.from=r,this.to=o}hasResult(){return!0}updateFor(e,t){var i;if(!(t&3))return this.map(e.changes);let s=this.result;s.map&&!e.changes.empty&&(s=s.map(s,e.changes));let r=e.changes.mapPos(this.from),o=e.changes.mapPos(this.to,1),l=Qt(e.state);if(l>o||!s||t&2&&(Qt(e.startState)==this.from||lt.map(e))}}),oe=J.define({create(){return fa.start()},update(n,e){return n.update(e)},provide:n=>[wi.from(n,e=>e.tooltip),A.contentAttributes.from(n,e=>e.attrs)]});function ba(n,e){let t=e.completion.apply||e.completion.label,i=n.state.field(oe).active.find(s=>s.source==e.source);return i instanceof rr?(typeof t=="string"?n.dispatch({...nd(n.state,t,i.from,i.to),annotations:or.of(e.completion)}):t(n,e.completion,i.from,i.to),!0):!1}var Qy=$y(oe,ba);function xn(n,e="option"){return t=>{let i=t.state.field(oe,!1);if(!i||!i.open||i.open.disabled||Date.now()-i.open.timestamp-1?i.open.selected+s*(n?1:-1):n?0:o-1;return l<0?l=e=="page"?0:o-1:l>=o&&(l=e=="page"?o-1:0),t.dispatch({effects:lr.of(l)}),!0}}var rd=n=>{let e=n.state.field(oe,!1);return n.state.readOnly||!e||!e.open||e.open.selected<0||e.open.disabled||Date.now()-e.open.timestampn.state.field(oe,!1)?(n.dispatch({effects:sr.of(!0)}),!0):!1,od=n=>{let e=n.state.field(oe,!1);return!e||!e.active.some(t=>t.state!=0)?!1:(n.dispatch({effects:vn.of(null)}),!0)},ua=class{constructor(e,t){this.active=e,this.context=t,this.time=Date.now(),this.updates=[],this.done=void 0}},Zy=50,eb=1e3,tb=Y.fromClass(class{constructor(n){this.view=n,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.pendingStart=!1,this.composing=0;for(let e of n.state.field(oe).active)e.isPending&&this.startQuery(e)}update(n){let e=n.state.field(oe),t=n.state.facet(le);if(!n.selectionSet&&!n.docChanged&&n.startState.field(oe)==e)return;let i=n.transactions.some(r=>{let o=sd(r,t);return o&8||(r.selection||r.docChanged)&&!(o&3)});for(let r=0;rZy&&Date.now()-o.time>eb){for(let l of o.context.abortListeners)try{l()}catch(a){re(this.view.state,a)}o.context.abortListeners=null,this.running.splice(r--,1)}else o.updates.push(...n.transactions)}this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),n.transactions.some(r=>r.effects.some(o=>o.is(sr)))&&(this.pendingStart=!0);let s=this.pendingStart?50:t.activateOnTypingDelay;if(this.debounceUpdate=e.active.some(r=>r.isPending&&!this.running.some(o=>o.active.source==r.source))?setTimeout(()=>this.startUpdate(),s):-1,this.composing!=0)for(let r of n.transactions)r.isUserEvent("input.type")?this.composing=2:this.composing==2&&r.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1,this.pendingStart=!1;let{state:n}=this.view,e=n.field(oe);for(let t of e.active)t.isPending&&!this.running.some(i=>i.active.source==t.source)&&this.startQuery(t);this.running.length&&e.open&&e.open.disabled&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(le).updateSyncTime))}startQuery(n){let{state:e}=this.view,t=Qt(e),i=new wn(e,t,n.explicit,this.view),s=new ua(n,i);this.running.push(s),Promise.resolve(n.source(i)).then(r=>{s.context.aborted||(s.done=r||null,this.scheduleAccept())},r=>{this.view.dispatch({effects:vn.of(null)}),re(this.view.state,r)})}scheduleAccept(){this.running.every(n=>n.done!==void 0)?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout(()=>this.accept(),this.view.state.facet(le).updateSyncTime))}accept(){var n;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],t=this.view.state.facet(le),i=this.view.state.field(oe);for(let s=0;sl.source==r.active.source);if(o&&o.isPending)if(r.done==null){let l=new mt(r.active.source,0);for(let a of r.updates)l=l.update(a,t);l.isPending||e.push(l)}else this.startQuery(o)}(e.length||i.open&&i.open.disabled)&&this.view.dispatch({effects:ya.of(e)})}},{eventHandlers:{blur(n){let e=this.view.state.field(oe,!1);if(e&&e.tooltip&&this.view.state.facet(le).closeOnBlur){let t=e.open&&tl(this.view,e.open.tooltip);(!t||!t.dom.contains(n.relatedTarget))&&setTimeout(()=>this.view.dispatch({effects:vn.of(null)}),10)}},compositionstart(){this.composing=1},compositionend(){this.composing==3&&setTimeout(()=>this.view.dispatch({effects:sr.of(!1)}),20),this.composing=0}}}),ib=typeof navigator=="object"&&/Win/.test(navigator.platform),nb=Oe.highest(A.domEventHandlers({keydown(n,e){let t=e.state.field(oe,!1);if(!t||!t.open||t.open.disabled||t.open.selected<0||n.key.length>1||n.ctrlKey&&!(ib&&n.altKey)||n.metaKey)return!1;let i=t.open.options[t.open.selected],s=t.active.find(o=>o.source==i.source),r=i.completion.commitCharacters||s.result.commitCharacters;return r&&r.indexOf(n.key)>-1&&ba(e,i),!1}})),ld=A.baseTheme({".cm-tooltip.cm-tooltip-autocomplete":{"& > ul":{fontFamily:"monospace",whiteSpace:"nowrap",overflow:"hidden auto",maxWidth_fallback:"700px",maxWidth:"min(700px, 95vw)",minWidth:"250px",maxHeight:"10em",height:"100%",listStyle:"none",margin:0,padding:0,"& > li, & > completion-section":{padding:"1px 3px",lineHeight:1.2},"& > li":{overflowX:"hidden",textOverflow:"ellipsis",cursor:"pointer"},"& > completion-section":{display:"list-item",borderBottom:"1px solid silver",paddingLeft:"0.5em",opacity:.7}}},"&light .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#17c",color:"white"},"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#777"},"&dark .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#347",color:"white"},"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#444"},".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after":{content:'"\xB7\xB7\xB7"',opacity:.5,display:"block",textAlign:"center",cursor:"pointer"},".cm-tooltip.cm-completionInfo":{position:"absolute",padding:"3px 9px",width:"max-content",maxWidth:"400px",boxSizing:"border-box",whiteSpace:"pre-line"},".cm-completionInfo.cm-completionInfo-left":{right:"100%"},".cm-completionInfo.cm-completionInfo-right":{left:"100%"},".cm-completionInfo.cm-completionInfo-left-narrow":{right:"30px"},".cm-completionInfo.cm-completionInfo-right-narrow":{left:"30px"},"&light .cm-snippetField":{backgroundColor:"#00000022"},"&dark .cm-snippetField":{backgroundColor:"#ffffff22"},".cm-snippetFieldPosition":{verticalAlign:"text-top",width:0,height:"1.15em",display:"inline-block",margin:"0 -0.7px -.7em",borderLeft:"1.4px dotted #888"},".cm-completionMatchedText":{textDecoration:"underline"},".cm-completionDetail":{marginLeft:"0.5em",fontStyle:"italic"},".cm-completionIcon":{fontSize:"90%",width:".8em",display:"inline-block",textAlign:"center",paddingRight:".6em",opacity:"0.6",boxSizing:"content-box"},".cm-completionIcon-function, .cm-completionIcon-method":{"&:after":{content:"'\u0192'"}},".cm-completionIcon-class":{"&:after":{content:"'\u25CB'"}},".cm-completionIcon-interface":{"&:after":{content:"'\u25CC'"}},".cm-completionIcon-variable":{"&:after":{content:"'\u{1D465}'"}},".cm-completionIcon-constant":{"&:after":{content:"'\u{1D436}'"}},".cm-completionIcon-type":{"&:after":{content:"'\u{1D461}'"}},".cm-completionIcon-enum":{"&:after":{content:"'\u222A'"}},".cm-completionIcon-property":{"&:after":{content:"'\u25A1'"}},".cm-completionIcon-keyword":{"&:after":{content:"'\u{1F511}\uFE0E'"}},".cm-completionIcon-namespace":{"&:after":{content:"'\u25A2'"}},".cm-completionIcon-text":{"&:after":{content:"'abc'",fontSize:"50%",verticalAlign:"middle"}}}),da=class{constructor(e,t,i,s){this.field=e,this.line=t,this.from=i,this.to=s}},pa=class n{constructor(e,t,i){this.field=e,this.from=t,this.to=i}map(e){let t=e.mapPos(this.from,-1,se.TrackDel),i=e.mapPos(this.to,1,se.TrackDel);return t==null||i==null?null:new n(this.field,t,i)}},ma=class n{constructor(e,t){this.lines=e,this.fieldPositions=t}instantiate(e,t){let i=[],s=[t],r=e.doc.lineAt(t),o=/^\s*/.exec(r.text)[0];for(let a of this.lines){if(i.length){let h=o,c=/^\t*/.exec(a)[0].length;for(let f=0;fnew pa(a.field,s[a.line]+a.from,s[a.line]+a.to));return{text:i,ranges:l}}static parse(e){let t=[],i=[],s=[],r;for(let o of e.split(/\r\n?|\n/)){for(;r=/[#$]\{(?:(\d+)(?::([^{}]*))?|((?:\\[{}]|[^{}])*))\}/.exec(o);){let l=r[1]?+r[1]:null,a=r[2]||r[3]||"",h=-1;l===0&&(l=1e9);let c=a.replace(/\\[{}]/g,f=>f[1]);for(let f=0;f=h&&u.field++}for(let f of s)if(f.line==i.length&&f.from>r.index){let u=r[2]?3+(r[1]||"").length:2;f.from-=u,f.to-=u}s.push(new da(h,i.length,r.index,r.index+c.length)),o=o.slice(0,r.index)+a+o.slice(r.index+r[0].length)}o=o.replace(/\\([{}])/g,(l,a,h)=>{for(let c of s)c.line==i.length&&c.from>h&&(c.from--,c.to--);return a}),i.push(o)}return new n(i,s)}},sb=B.widget({widget:new class extends xe{toDOM(){let n=document.createElement("span");return n.className="cm-snippetFieldPosition",n}ignoreEvent(){return!1}}}),rb=B.mark({class:"cm-snippetField"}),Oi=class n{constructor(e,t){this.ranges=e,this.active=t,this.deco=B.set(e.map(i=>(i.from==i.to?sb:rb).range(i.from,i.to)),!0)}map(e){let t=[];for(let i of this.ranges){let s=i.map(e);if(!s)return null;t.push(s)}return new n(t,this.active)}selectionInsideField(e){return e.ranges.every(t=>this.ranges.some(i=>i.field==this.active&&i.from<=t.from&&i.to>=t.to))}},Sn=L.define({map(n,e){return n&&n.map(e)}}),ob=L.define(),Zt=J.define({create(){return null},update(n,e){for(let t of e.effects){if(t.is(Sn))return t.value;if(t.is(ob)&&n)return new Oi(n.ranges,t.value)}return n&&e.docChanged&&(n=n.map(e.changes)),n&&e.selection&&!n.selectionInsideField(e.selection)&&(n=null),n},provide:n=>A.decorations.from(n,e=>e?e.deco:B.none)});function xa(n,e){return b.create(n.filter(t=>t.field==e).map(t=>b.range(t.from,t.to)))}function ad(n){let e=ma.parse(n);return(t,i,s,r)=>{let{text:o,ranges:l}=e.instantiate(t.state,s),{main:a}=t.state.selection,h={changes:{from:s,to:r==a.from?a.to:r,insert:F.of(o)},scrollIntoView:!0,annotations:i?[or.of(i),ie.userEvent.of("input.complete")]:void 0};if(l.length&&(h.selection=xa(l,0)),l.some(c=>c.field>0)){let c=new Oi(l,0),f=h.effects=[Sn.of(c)];t.state.field(Zt,!1)===void 0&&f.push(L.appendConfig.of([Zt,cb,ub,ld]))}t.dispatch(t.state.update(h))}}function hd(n){return({state:e,dispatch:t})=>{let i=e.field(Zt,!1);if(!i||n<0&&i.active==0)return!1;let s=i.active+n,r=n>0&&!i.ranges.some(o=>o.field==s+n);return t(e.update({selection:xa(i.ranges,s),effects:Sn.of(r?null:new Oi(i.ranges,s)),scrollIntoView:!0})),!0}}var cd=({state:n,dispatch:e})=>n.field(Zt,!1)?(e(n.update({effects:Sn.of(null)})),!0):!1,fd=hd(1),ud=hd(-1);function lb(n){let e=n.field(Zt,!1);return!!(e&&e.ranges.some(t=>t.field==e.active+1))}function ab(n){let e=n.field(Zt,!1);return!!(e&&e.active>0)}var hb=[{key:"Tab",run:fd,shift:ud},{key:"Escape",run:cd}],ga=C.define({combine(n){return n.length?n[0]:hb}}),cb=Oe.highest(Kt.compute([ga],n=>n.facet(ga)));function fb(n,e){return{...e,apply:ad(n)}}var ub=A.domEventHandlers({mousedown(n,e){let t=e.state.field(Zt,!1),i;if(!t||(i=e.posAtCoords({x:n.clientX,y:n.clientY}))==null)return!1;let s=t.ranges.find(r=>r.from<=i&&r.to>=i);return!s||s.field==t.active?!1:(e.dispatch({selection:xa(t.ranges,s.field),effects:Sn.of(t.ranges.some(r=>r.field>s.field)?new Oi(t.ranges,s.field):null),scrollIntoView:!0}),!0)}});function db(n){let e=n.replace(/[\]\-\\]/g,"\\$&");try{return new RegExp(`[\\p{Alphabetic}\\p{Number}_${e}]+`,"ug")}catch{return new RegExp(`[w${e}]`,"g")}}function Yu(n,e){return new RegExp(e(n.source),n.unicode?"u":"")}var Ju=Object.create(null);function pb(n){return Ju[n]||(Ju[n]=new WeakMap)}function Xu(n,e,t,i,s){for(let r=n.iterLines(),o=0;!r.next().done;){let{value:l}=r,a;for(e.lastIndex=0;a=e.exec(l);)if(!i[a[0]]&&o+a.index!=s&&(t.push({type:"text",label:a[0]}),i[a[0]]=!0,t.length>=2e3))return;o+=l.length+1}}function dd(n,e,t,i,s){let r=n.length>=1e3,o=r&&e.get(n);if(o)return o;let l=[],a=Object.create(null);if(n.children){let h=0;for(let c of n.children){if(c.length>=1e3)for(let f of dd(c,e,t,i-h,s-h))a[f.label]||(a[f.label]=!0,l.push(f));else Xu(c,t,l,a,s-h);h+=c.length+1}}else Xu(n,t,l,a,s);return r&&l.length<2e3&&e.set(n,l),l}var mb=n=>{var e;let t=(e=n.state.languageDataAt("wordChars",n.pos)[0])!==null&&e!==void 0?e:"",i=db(t),s=n.matchBefore(Yu(i,l=>l+"$"));if(!s&&!n.explicit)return null;let r=s?s.from:n.pos,o=dd(n.state.doc,pb(t),i,5e4,r);return{from:r,options:o,validFor:Yu(i,l=>"^"+l)}},kn={brackets:["(","[","{","'",'"'],before:")]}:;>",stringPrefixes:[]},Xt=L.define({map(n,e){let t=e.mapPos(n,-1,se.TrackAfter);return t??void 0}}),wa=new class extends Pe{};wa.startSide=1;wa.endSide=-1;var pd=J.define({create(){return I.empty},update(n,e){if(n=n.map(e.changes),e.selection){let t=e.state.doc.lineAt(e.selection.main.head);n=n.update({filter:i=>i>=t.from&&i<=t.to})}for(let t of e.effects)t.is(Xt)&&(n=n.update({add:[wa.range(t.value,t.value+1)]}));return n}});function va(){return[yb,pd]}var oa="()[]{}<>\xAB\xBB\xBB\xAB\uFF3B\uFF3D\uFF5B\uFF5D";function md(n){for(let e=0;e{if((gb?n.composing:n.compositionStarted)||n.state.readOnly)return!1;let s=n.state.selection.main;if(i.length>2||i.length==2&&De(he(i,0))==1||e!=s.from||t!=s.to)return!1;let r=bd(n.state,i);return r?(n.dispatch(r),!0):!1}),yd=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let i=gd(n,n.selection.main.head).brackets||kn.brackets,s=null,r=n.changeByRange(o=>{if(o.empty){let l=bb(n.doc,o.head);for(let a of i)if(a==l&&ar(n.doc,o.head)==md(he(a,0)))return{changes:{from:o.head-a.length,to:o.head+a.length},range:b.cursor(o.head-a.length)}}return{range:s=o}});return s||e(n.update(r,{scrollIntoView:!0,userEvent:"delete.backward"})),!s},ka=[{key:"Backspace",run:yd}];function bd(n,e){let t=gd(n,n.selection.main.head),i=t.brackets||kn.brackets;for(let s of i){let r=md(he(s,0));if(e==s)return r==s?vb(n,s,i.indexOf(s+s+s)>-1,t):xb(n,s,r,t.before||kn.before);if(e==r&&xd(n,n.selection.main.from))return wb(n,s,r)}return null}function xd(n,e){let t=!1;return n.field(pd).between(0,n.doc.length,i=>{i==e&&(t=!0)}),t}function ar(n,e){let t=n.sliceString(e,e+2);return t.slice(0,De(he(t,0)))}function bb(n,e){let t=n.sliceString(e-2,e);return De(he(t,0))==t.length?t:t.slice(1)}function xb(n,e,t,i){let s=null,r=n.changeByRange(o=>{if(!o.empty)return{changes:[{insert:e,from:o.from},{insert:t,from:o.to}],effects:Xt.of(o.to+e.length),range:b.range(o.anchor+e.length,o.head+e.length)};let l=ar(n.doc,o.head);return!l||/\s/.test(l)||i.indexOf(l)>-1?{changes:{insert:e+t,from:o.head},effects:Xt.of(o.head+e.length),range:b.cursor(o.head+e.length)}:{range:s=o}});return s?null:n.update(r,{scrollIntoView:!0,userEvent:"input.type"})}function wb(n,e,t){let i=null,s=n.changeByRange(r=>r.empty&&ar(n.doc,r.head)==t?{changes:{from:r.head,to:r.head+t.length,insert:t},range:b.cursor(r.head+t.length)}:i={range:r});return i?null:n.update(s,{scrollIntoView:!0,userEvent:"input.type"})}function vb(n,e,t,i){let s=i.stringPrefixes||kn.stringPrefixes,r=null,o=n.changeByRange(l=>{if(!l.empty)return{changes:[{insert:e,from:l.from},{insert:e,from:l.to}],effects:Xt.of(l.to+e.length),range:b.range(l.anchor+e.length,l.head+e.length)};let a=l.head,h=ar(n.doc,a),c;if(h==e){if(Qu(n,a))return{changes:{insert:e+e,from:a},effects:Xt.of(a+e.length),range:b.cursor(a+e.length)};if(xd(n,a)){let u=t&&n.sliceDoc(a,a+e.length*3)==e+e+e?e+e+e:e;return{changes:{from:a,to:a+u.length,insert:u},range:b.cursor(a+u.length)}}}else{if(t&&n.sliceDoc(a-2*e.length,a)==e+e&&(c=Zu(n,a-2*e.length,s))>-1&&Qu(n,c))return{changes:{insert:e+e+e+e,from:a},effects:Xt.of(a+e.length),range:b.cursor(a+e.length)};if(n.charCategorizer(a)(h)!=K.Word&&Zu(n,a,s)>-1&&!kb(n,a,e,s))return{changes:{insert:e+e,from:a},effects:Xt.of(a+e.length),range:b.cursor(a+e.length)}}return{range:r=l}});return r?null:n.update(o,{scrollIntoView:!0,userEvent:"input.type"})}function Qu(n,e){let t=Z(n).resolveInner(e+1);return t.parent&&t.from==e}function kb(n,e,t,i){let s=Z(n).resolveInner(e,-1),r=i.reduce((o,l)=>Math.max(o,l.length),0);for(let o=0;o<5;o++){let l=n.sliceDoc(s.from,Math.min(s.to,s.from+t.length+r)),a=l.indexOf(t);if(!a||a>-1&&i.indexOf(l.slice(0,a))>-1){let c=s.firstChild;for(;c&&c.from==s.from&&c.to-c.from>t.length+a;){if(n.sliceDoc(c.to-t.length,c.to)==t)return!1;c=c.firstChild}return!0}let h=s.to==e&&s.parent;if(!h)break;s=h}return!1}function Zu(n,e,t){let i=n.charCategorizer(e);if(i(n.sliceDoc(e-1,e))!=K.Word)return e;for(let s of t){let r=e-s.length;if(n.sliceDoc(r,e)==s&&i(n.sliceDoc(r-1,r))!=K.Word)return r}return-1}function Sa(n={}){return[nb,oe,le.of(n),tb,Sb,ld]}var hr=[{key:"Ctrl-Space",run:ir},{mac:"Alt-`",run:ir},{mac:"Alt-i",run:ir},{key:"Escape",run:od},{key:"ArrowDown",run:xn(!0)},{key:"ArrowUp",run:xn(!1)},{key:"PageDown",run:xn(!0,"page")},{key:"PageUp",run:xn(!1,"page")},{key:"Enter",run:rd}],Sb=Oe.highest(Kt.computeN([le],n=>n.facet(le).defaultKeymap?[hr]:[]));function Cb(n){let e=n.field(oe,!1);return e&&e.active.some(t=>t.isPending)?"pending":e&&e.active.some(t=>t.state!=0)?"active":null}var ed=new WeakMap;function Ab(n){var e;let t=(e=n.field(oe,!1))===null||e===void 0?void 0:e.open;if(!t||t.disabled)return[];let i=ed.get(t.options);return i||ed.set(t.options,i=t.options.map(s=>s.completion)),i}function Mb(n){var e;let t=(e=n.field(oe,!1))===null||e===void 0?void 0:e.open;return t&&!t.disabled&&t.selected>=0?t.options[t.selected].completion:null}function Tb(n){var e;let t=(e=n.field(oe,!1))===null||e===void 0?void 0:e.open;return t&&!t.disabled&&t.selected>=0?t.selected:null}function Db(n){return lr.of(n)}var Id={};Tn(Id,{closeLintPanel:()=>Ca,diagnosticCount:()=>Ob,forEachDiagnostic:()=>$b,forceLinting:()=>Ib,lintGutter:()=>Kb,lintKeymap:()=>Da,linter:()=>Pb,nextDiagnostic:()=>Dd,openLintPanel:()=>Td,previousDiagnostic:()=>Eb,setDiagnostics:()=>Cd,setDiagnosticsEffect:()=>An});var ur=class{constructor(e,t,i){this.from=e,this.to=t,this.diagnostic=i}},ei=class n{constructor(e,t,i){this.diagnostics=e,this.panel=t,this.selected=i}static init(e,t,i){let s=i.facet(nt).markerFilter;s&&(e=s(e,i));let r=e.slice().sort((d,p)=>d.from-p.from||d.to-p.to),o=new Se,l=[],a=0,h=i.doc.iter(),c=0,f=i.doc.length;for(let d=0;;){let p=d==r.length?null:r[d];if(!p&&!l.length)break;let m,g;if(l.length)m=a,g=l.reduce((w,O)=>Math.min(w,O.to),p&&p.from>m?p.from:1e8);else{if(m=p.from,m>f)break;g=p.to,l.push(p),d++}for(;dw.from||w.to==m))l.push(w),d++,g=Math.min(w.to,g);else{g=Math.min(w.from,g);break}}g=Math.min(g,f);let y=!1;if(l.some(w=>w.from==m&&(w.to==g||g==f))&&(y=m==g,!y&&g-m<10)){let w=m-(c+h.value.length);w>0&&(h.next(w),c=m);for(let O=m;;){if(O>=g){y=!0;break}if(!h.lineBreak&&c+h.value.length>O)break;O=c+h.value.length,c+=h.value.length,h.next()}}let x=Ed(l);if(y)o.add(m,m,B.widget({widget:new Aa(x),diagnostics:l.slice()}));else{let w=l.reduce((O,v)=>v.markClass?O+" "+v.markClass:O,"");o.add(m,g,B.mark({class:"cm-lintRange cm-lintRange-"+x+w,diagnostics:l.slice(),inclusiveEnd:l.some(O=>O.to>g)}))}if(a=g,a==f)break;for(let w=0;w{if(!(e&&o.diagnostics.indexOf(e)<0))if(!i)i=new ur(s,r,e||o.diagnostics[0]);else{if(o.diagnostics.indexOf(i.diagnostic)<0)return!1;i=new ur(i.from,r,i.diagnostic)}}),i}function kd(n,e){let t=e.pos,i=e.end||t,s=n.state.facet(nt).hideOn(n,t,i);if(s!=null)return s;let r=n.startState.doc.lineAt(e.pos);return!!(n.effects.some(o=>o.is(An))||n.changes.touchesRange(r.from,Math.max(r.to,i)))}function Sd(n,e){return n.field(me,!1)?e:e.concat(L.appendConfig.of(Pd))}function Cd(n,e){return{effects:Sd(n,[An.of(e)])}}var An=L.define(),Ta=L.define(),Ad=L.define(),me=J.define({create(){return new ei(B.none,null,null)},update(n,e){if(e.docChanged&&n.diagnostics.size){let t=n.diagnostics.map(e.changes),i=null,s=n.panel;if(n.selected){let r=e.changes.mapPos(n.selected.from,1);i=Ot(t,n.selected.diagnostic,r)||Ot(t,null,r)}!t.size&&s&&e.state.facet(nt).autoPanel&&(s=null),n=new ei(t,s,i)}for(let t of e.effects)if(t.is(An)){let i=e.state.facet(nt).autoPanel?t.value.length?Cn.open:null:n.panel;n=ei.init(t.value,i,e.state)}else t.is(Ta)?n=new ei(n.diagnostics,t.value?Cn.open:null,n.selected):t.is(Ad)&&(n=new ei(n.diagnostics,n.panel,t.value));return n},provide:n=>[qt.from(n,e=>e.panel),A.decorations.from(n,e=>e.diagnostics)]});function Ob(n){let e=n.field(me,!1);return e?e.diagnostics.size:0}var Bb=B.mark({class:"cm-lintRange cm-lintRange-active"});function Lb(n,e,t){let{diagnostics:i}=n.state.field(me),s,r=-1,o=-1;i.between(e-(t<0?1:0),e+(t>0?1:0),(a,h,{spec:c})=>{if(e>=a&&e<=h&&(a==h||(e>a||t>0)&&(eLd(n,t,!1)))}var Td=n=>{let e=n.state.field(me,!1);(!e||!e.panel)&&n.dispatch({effects:Sd(n.state,[Ta.of(!0)])});let t=Zi(n,Cn.open);return t&&t.dom.querySelector(".cm-panel-lint ul").focus(),!0},Ca=n=>{let e=n.state.field(me,!1);return!e||!e.panel?!1:(n.dispatch({effects:Ta.of(!1)}),!0)},Dd=n=>{let e=n.state.field(me,!1);if(!e)return!1;let t=n.state.selection.main,i=Ot(e.diagnostics,null,t.to+1);return!i&&(i=Ot(e.diagnostics,null,0),!i||i.from==t.from&&i.to==t.to)?!1:(n.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0}),el(n,i.from,1,{tooltip:Ba,until:s=>s.docChanged||s.newSelection.main.headi.to}),!0)},Eb=n=>{var e;let{state:t}=n,i=t.field(me,!1);if(!i)return!1;let s=t.selection.main,r,o,l,a;if(i.diagnostics.between(0,t.doc.length,(f,u)=>{ul)&&(l=f,a=u)}),l==null||r==null&&l==s.from)return!1;let h=r??l,c=(e=o??a)!==null&&e!==void 0?e:h;return n.dispatch({selection:{anchor:h,head:c},scrollIntoView:!0}),el(n,h,1,{tooltip:Ba,until:f=>f.docChanged||f.newSelection.main.headc}),!0},Da=[{key:"Mod-Shift-m",run:Td,preventDefault:!0},{key:"F8",run:Dd}],Od=Y.fromClass(class{constructor(n){this.view=n,this.timeout=-1,this.set=!0;let{delay:e}=n.state.facet(nt);this.lintTime=Date.now()+e,this.run=this.run.bind(this),this.timeout=setTimeout(this.run,e)}run(){clearTimeout(this.timeout);let n=Date.now();if(nPromise.resolve(i(this.view))),i=>{this.view.state.doc==e.doc&&this.view.dispatch(Cd(this.view.state,i.reduce((s,r)=>s.concat(r))))},i=>{re(this.view.state,i)})}}update(n){let e=n.state.facet(nt);(n.docChanged||e!=n.startState.facet(nt)||e.needsRefresh&&e.needsRefresh(n))&&(this.lintTime=Date.now()+e.delay,this.set||(this.set=!0,this.timeout=setTimeout(this.run,e.delay)))}force(){this.set&&(this.lintTime=Date.now(),this.run())}destroy(){clearTimeout(this.timeout)}});function Rb(n,e,t){let i=[],s=-1;for(let r of n)r.then(o=>{i.push(o),clearTimeout(s),i.length==n.length?e(i):s=setTimeout(()=>e(i),200)},t)}var nt=C.define({combine(n){return{sources:n.map(e=>e.source).filter(e=>e!=null),...ce(n.map(e=>e.config),{delay:750,markerFilter:null,tooltipFilter:null,needsRefresh:null,hideOn:()=>null},{delay:Math.max,markerFilter:vd,tooltipFilter:vd,needsRefresh:(e,t)=>e?t?i=>e(i)||t(i):e:t,hideOn:(e,t)=>e?t?(i,s,r)=>e(i,s,r)||t(i,s,r):e:t,autoPanel:(e,t)=>e||t})}}});function vd(n,e){return n?e?(t,i)=>e(n(t,i),i):n:e}function Pb(n,e={}){return[nt.of({source:n,config:e}),Od,Pd]}function Ib(n){let e=n.plugin(Od);e&&e.force()}function Bd(n){let e=[];if(n)e:for(let{name:t}of n){for(let i=0;ir.toLowerCase()==s.toLowerCase())){e.push(s);continue e}}e.push("")}return e}function Ld(n,e,t){var i;let s=t?Bd(e.actions):[];return W("li",{class:"cm-diagnostic cm-diagnostic-"+e.severity},W("span",{class:"cm-diagnosticText"},e.renderMessage?e.renderMessage(n):e.message),(i=e.actions)===null||i===void 0?void 0:i.map((r,o)=>{let l=!1,a=d=>{if(d.preventDefault(),l)return;l=!0;let p=Ot(n.state.field(me).diagnostics,e);p&&r.apply(n,p.from,p.to)},{name:h}=r,c=s[o]?h.indexOf(s[o]):-1,f=c<0?h:[h.slice(0,c),W("u",h.slice(c,c+1)),h.slice(c+1)],u=r.markClass?" "+r.markClass:"";return W("button",{type:"button",class:"cm-diagnosticAction"+u,onclick:a,onmousedown:a,"aria-label":` Action: ${h}${c<0?"":` (access key "${s[o]})"`}.`},f)}),e.source&&W("div",{class:"cm-diagnosticSource"},e.source))}var Aa=class extends xe{constructor(e){super(),this.sev=e}eq(e){return e.sev==this.sev}toDOM(){return W("span",{class:"cm-lintPoint cm-lintPoint-"+this.sev})}},dr=class{constructor(e,t){this.diagnostic=t,this.id="item_"+Math.floor(Math.random()*4294967295).toString(16),this.dom=Ld(e,t,!0),this.dom.id=this.id,this.dom.setAttribute("role","option")}},Cn=class n{constructor(e){this.view=e,this.items=[];let t=s=>{if(!(s.ctrlKey||s.altKey||s.metaKey)){if(s.keyCode==27)Ca(this.view),this.view.focus();else if(s.keyCode==38||s.keyCode==33)this.moveSelection((this.selectedIndex-1+this.items.length)%this.items.length);else if(s.keyCode==40||s.keyCode==34)this.moveSelection((this.selectedIndex+1)%this.items.length);else if(s.keyCode==36)this.moveSelection(0);else if(s.keyCode==35)this.moveSelection(this.items.length-1);else if(s.keyCode==13)this.view.focus();else if(s.keyCode>=65&&s.keyCode<=90&&this.selectedIndex>=0){let{diagnostic:r}=this.items[this.selectedIndex],o=Bd(r.actions);for(let l=0;l{for(let r=0;rCa(this.view)},"\xD7")),this.update()}get selectedIndex(){let e=this.view.state.field(me).selected;if(!e)return-1;for(let t=0;t{for(let c of h.diagnostics){if(o.has(c))continue;o.add(c);let f=-1,u;for(let d=i;di&&(this.items.splice(i,f-i),s=!0)),t&&u.diagnostic==t.diagnostic?u.dom.hasAttribute("aria-selected")||(u.dom.setAttribute("aria-selected","true"),r=u):u.dom.hasAttribute("aria-selected")&&u.dom.removeAttribute("aria-selected"),i++}});i({sel:r.dom.getBoundingClientRect(),panel:this.list.getBoundingClientRect()}),write:({sel:l,panel:a})=>{let h=a.height/this.list.offsetHeight;l.topa.bottom&&(this.list.scrollTop+=(l.bottom-a.bottom)/h)}})):this.selectedIndex<0&&this.list.removeAttribute("aria-activedescendant"),s&&this.sync()}sync(){let e=this.list.firstChild;function t(){let i=e;e=i.nextSibling,i.remove()}for(let i of this.items)if(i.dom.parentNode==this.list){for(;e!=i.dom;)t();e=i.dom.nextSibling}else this.list.insertBefore(i.dom,e);for(;e;)t()}moveSelection(e){if(this.selectedIndex<0)return;let t=this.view.state.field(me),i=Ot(t.diagnostics,this.items[e].diagnostic);i&&this.view.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0,effects:Ad.of(i)})}static open(e){return new n(e)}};function fr(n,e='viewBox="0 0 40 40"'){return`url('data:image/svg+xml,${encodeURIComponent(n)}')`}function cr(n){return fr(``,'width="6" height="3"')}var Nb=A.baseTheme({".cm-diagnostic":{padding:"3px 6px 3px 8px",marginLeft:"-1px",display:"block",whiteSpace:"pre-wrap"},".cm-diagnostic-error":{borderLeft:"5px solid #d11"},".cm-diagnostic-warning":{borderLeft:"5px solid orange"},".cm-diagnostic-info":{borderLeft:"5px solid #999"},".cm-diagnostic-hint":{borderLeft:"5px solid #66d"},".cm-diagnosticAction":{font:"inherit",border:"none",padding:"2px 4px",backgroundColor:"#444",color:"white",borderRadius:"3px",marginLeft:"8px",cursor:"pointer"},".cm-diagnosticSource":{fontSize:"70%",opacity:.7},".cm-lintRange":{backgroundPosition:"left bottom",backgroundRepeat:"repeat-x",paddingBottom:"0.7px"},".cm-lintRange-error":{backgroundImage:cr("#f11")},".cm-lintRange-warning":{backgroundImage:cr("orange")},".cm-lintRange-info":{backgroundImage:cr("#999")},".cm-lintRange-hint":{backgroundImage:cr("#66d")},".cm-lintRange-active":{backgroundColor:"#ffdd9980"},".cm-tooltip-lint":{padding:0,margin:0},".cm-lintPoint":{position:"relative","&:after":{content:'""',position:"absolute",bottom:0,left:"-2px",borderLeft:"3px solid transparent",borderRight:"3px solid transparent",borderBottom:"4px solid #d11"}},".cm-lintPoint-warning":{"&:after":{borderBottomColor:"orange"}},".cm-lintPoint-info":{"&:after":{borderBottomColor:"#999"}},".cm-lintPoint-hint":{"&:after":{borderBottomColor:"#66d"}},".cm-panel.cm-panel-lint":{position:"relative","& ul":{maxHeight:"100px",overflowY:"auto","& [aria-selected]":{backgroundColor:"#ddd","& u":{textDecoration:"underline"}},"&:focus [aria-selected]":{background_fallback:"#bdf",backgroundColor:"Highlight",color_fallback:"white",color:"HighlightText"},"& u":{textDecoration:"none"},padding:0,margin:0},"& [name=close]":{position:"absolute",top:"0",right:"2px",background:"inherit",border:"none",font:"inherit",padding:0,margin:0}},"&dark .cm-lintRange-active":{backgroundColor:"#86714a80"},"&dark .cm-panel.cm-panel-lint ul":{"& [aria-selected]":{backgroundColor:"#2e343e"}}});function Fb(n){return n=="error"?4:n=="warning"?3:n=="info"?2:1}function Ed(n){let e="hint",t=1;for(let i of n){let s=Fb(i.severity);s>t&&(t=s,e=i.severity)}return e}var pr=class extends Ae{constructor(e){super(),this.diagnostics=e,this.severity=Ed(e)}toDOM(e){let t=document.createElement("div");t.className="cm-lint-marker cm-lint-marker-"+this.severity;let i=this.diagnostics,s=e.state.facet(mr).tooltipFilter;return s&&(i=s(i,e.state)),i.length&&(t.onmouseover=()=>Wb(e,t,i)),t}};function Hb(n,e){let t=i=>{let s=e.getBoundingClientRect();if(!(i.clientX>s.left-10&&i.clientXs.top-10&&i.clientYe.getBoundingClientRect()}}})}),e.onmouseout=e.onmousemove=null,Hb(n,e)}let{hoverTime:s}=n.state.facet(mr),r=setTimeout(i,s);e.onmouseout=()=>{clearTimeout(r),e.onmouseout=e.onmousemove=null},e.onmousemove=()=>{clearTimeout(r),r=setTimeout(i,s)}}function Vb(n,e){let t=Object.create(null);for(let s of e){let r=n.lineAt(s.from);(t[r.from]||(t[r.from]=[])).push(s)}let i=[];for(let s in t)i.push(new pr(t[s]).range(+s));return I.of(i,!0)}var zb=ks({class:"cm-gutter-lint",markers:n=>n.state.field(Ma),widgetMarker:(n,e,t)=>{let i=[];return n.state.field(Ma).between(t.from,t.to,(s,r,o)=>{s>t.from&&si.is(Oa)?i.value:t,n)},provide:n=>wi.from(n)}),qb=A.baseTheme({".cm-gutter-lint":{width:"1.4em","& .cm-gutterElement":{padding:".2em"}},".cm-lint-marker":{width:"1em",height:"1em"},".cm-lint-marker-info":{content:fr('')},".cm-lint-marker-warning":{content:fr('')},".cm-lint-marker-error":{content:fr('')}}),Ba=Uc(Lb,{hideOn:kd}),Pd=[me,A.decorations.compute([me],n=>{let{selected:e,panel:t}=n.field(me);return!e||!t||e.from==e.to?B.none:B.set([Bb.range(e.from,e.to)])}),Ba,Nb],mr=C.define({combine(n){return ce(n,{hoverTime:300,markerFilter:null,tooltipFilter:null})}});function Kb(n={}){return[mr.of(n),Ma,zb,qb,Rd]}function $b(n,e){let t=n.field(me,!1);if(t&&t.diagnostics.size){let i=[],s=[],r=-1;for(let o=I.iter([t.diagnostics]);;o.next()){for(let l=0;lassertConfigurationIsInvalid( - [ - [], // no values at all + $this->assertProcessedConfigurationEquals([ + [], + ], [ + 'manual_adjustment_reasons' => ['goodwill', 'correction', 'promotion', 'other'], + 'triggers' => [], + 'expression_editor' => [ + 'cdn_base_url' => 'https://esm.sh', + ], + 'referral' => [ + 'query_parameter' => 'ref', + 'registration_ip_check' => false, + 'ip_hash_salt' => '%kernel.secret%', + 'reward_cap' => 10, + ], + 'retain_anonymized_ledger' => false, + 'resources' => [ + 'account' => self::resource(LoyaltyAccount::class, LoyaltyAccountInterface::class, LoyaltyAccountRepository::class), + 'program' => self::resource(LoyaltyProgram::class, LoyaltyProgramInterface::class, LoyaltyProgramRepository::class, LoyaltyProgramType::class), + 'transaction' => self::resource(LoyaltyTransaction::class, LoyaltyTransactionInterface::class, LoyaltyTransactionRepository::class), + 'earning_rule' => self::resource(EarningRule::class, EarningRuleInterface::class, EarningRuleRepository::class, EarningRuleType::class), + 'earning_rule_condition' => self::resource(EarningRuleCondition::class, EarningRuleConditionInterface::class, null, EarningRuleConditionType::class), + 'dry_run_result' => self::resource(DryRunResult::class, DryRunResultInterface::class), + 'referral' => self::resource(Referral::class, ReferralInterface::class, ReferralRepository::class), + 'tier' => self::resource(Tier::class, TierInterface::class, TierRepository::class, TierType::class) + [ + 'translation' => [ + 'classes' => [ + 'model' => TierTranslation::class, + 'interface' => TierTranslationInterface::class, + 'controller' => ResourceController::class, + 'factory' => Factory::class, + ], + ], + ], ], - '/The child (config|node) "option" (under|at path) "setono_sylius_loyalty" must be configured/', - true, - ); + ]); + } + + /** + * @param class-string $model + * @param class-string $interface + * @param class-string|null $repository + * @param class-string|null $form + * + * @return array{classes: array} + */ + private static function resource(string $model, string $interface, ?string $repository = null, ?string $form = null): array + { + $classes = [ + 'model' => $model, + 'interface' => $interface, + 'controller' => ResourceController::class, + 'factory' => Factory::class, + ]; + + if (null !== $form) { + $classes['form'] = $form; + } + + if (null !== $repository) { + $classes['repository'] = $repository; + } + + return ['classes' => $classes]; } /** * @test */ - public function processed_value_contains_required_value(): void + public function it_allows_overriding_manual_adjustment_reasons(): void { $this->assertProcessedConfigurationEquals([ - ['option' => 'first value'], - ['option' => 'last value'], + ['manual_adjustment_reasons' => ['goodwill', 'vip']], ], [ - 'option' => 'last value', - ]); + 'manual_adjustment_reasons' => ['goodwill', 'vip'], + ], 'manual_adjustment_reasons'); + } + + /** + * @test + */ + public function it_allows_registering_triggers(): void + { + $this->assertProcessedConfigurationEquals([ + ['triggers' => ['App\Event\NewsletterSubscribedTriggerEvent']], + ], [ + 'triggers' => ['App\Event\NewsletterSubscribedTriggerEvent'], + ], 'triggers'); } } diff --git a/tests/DependencyInjection/SetonoSyliusLoyaltyExtensionTest.php b/tests/DependencyInjection/SetonoSyliusLoyaltyExtensionTest.php index f640513..5f2f827 100644 --- a/tests/DependencyInjection/SetonoSyliusLoyaltyExtensionTest.php +++ b/tests/DependencyInjection/SetonoSyliusLoyaltyExtensionTest.php @@ -4,12 +4,9 @@ namespace Setono\SyliusLoyaltyPlugin\Tests\DependencyInjection; -use Setono\SyliusLoyaltyPlugin\DependencyInjection\SetonoSyliusLoyaltyExtension; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; +use Setono\SyliusLoyaltyPlugin\DependencyInjection\SetonoSyliusLoyaltyExtension; -/** - * See examples of tests and configuration options here: https://github.com/SymfonyTest/SymfonyDependencyInjectionTest - */ final class SetonoSyliusLoyaltyExtensionTest extends AbstractExtensionTestCase { protected function getContainerExtensions(): array @@ -19,23 +16,22 @@ protected function getContainerExtensions(): array ]; } - /** - * @return array - */ - protected function getMinimalConfiguration(): array - { - return [ - 'option' => 'option_value', - ]; - } - /** * @test */ - public function after_loading_the_correct_parameter_has_been_set(): void + public function after_loading_the_correct_parameters_have_been_set(): void { $this->load(); - $this->assertContainerBuilderHasParameter('setono_sylius_loyalty.option', 'option_value'); + $this->assertContainerBuilderHasParameter( + 'setono_sylius_loyalty.manual_adjustment_reasons', + ['goodwill', 'correction', 'promotion', 'other'], + ); + $this->assertContainerBuilderHasParameter('setono_sylius_loyalty.triggers', []); + $this->assertContainerBuilderHasParameter( + 'setono_sylius_loyalty.expression_editor.cdn_base_url', + 'https://esm.sh', + ); + $this->assertContainerBuilderHasParameter('setono_sylius_loyalty.driver', 'doctrine/orm'); } } diff --git a/tests/Functional/Earning/AsyncAwardOrderPointsTest.php b/tests/Functional/Earning/AsyncAwardOrderPointsTest.php new file mode 100644 index 0000000..eac89f5 --- /dev/null +++ b/tests/Functional/Earning/AsyncAwardOrderPointsTest.php @@ -0,0 +1,71 @@ +channel(); + $this->rule($channel, points: 1, perAmount: 100); + + $customer = $this->customer(); + $order = $this->paidOrder($channel, $customer, unitPrice: 2500, quantity: 2); // 50.00 + + $bus = $container->get(MessageBusInterface::class); + \assert($bus instanceof MessageBusInterface); + + // Dispatch the same message twice to the transport — a redelivery + $message = new AwardOrderPoints((int) $order->getId()); + $bus->dispatch($message, [new TransportNamesStamp('loyalty_async')]); + $bus->dispatch($message, [new TransportNamesStamp('loyalty_async')]); + + self::assertSame(2, $this->consumeAll($bus)); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + + self::assertSame(50, $accountProvider->getByCustomerAndChannel($customer, $channel)->getBalance()); + } + + /** + * In-process worker: pull every queued envelope and hand it to the bus as received. + */ + private function consumeAll(MessageBusInterface $bus): int + { + $receiver = self::getContainer()->get('messenger.transport.loyalty_async'); + \assert($receiver instanceof ReceiverInterface); + + $consumed = 0; + do { + $envelopes = [...$receiver->get()]; + foreach ($envelopes as $envelope) { + \assert($envelope instanceof Envelope); + $bus->dispatch($envelope->with(new ReceivedStamp('loyalty_async'))); + $receiver->ack($envelope); + ++$consumed; + } + } while ([] !== $envelopes); + + return $consumed; + } +} diff --git a/tests/Functional/Earning/AwardOrderPointsTest.php b/tests/Functional/Earning/AwardOrderPointsTest.php new file mode 100644 index 0000000..ff2b46f --- /dev/null +++ b/tests/Functional/Earning/AwardOrderPointsTest.php @@ -0,0 +1,82 @@ +entityManager(); + + // An isolated channel so no other rules interfere with the expected amounts + $channel = $this->channel(); + $this->rule($channel, points: 1, perAmount: 100); + + $customer = $this->customer(); + $order = $this->paidOrder($channel, $customer, unitPrice: 5000, quantity: 2); // 100.00 + + $handler = $container->get(AwardOrderPointsHandler::class); + \assert($handler instanceof AwardOrderPointsHandler); + + $message = new AwardOrderPoints((int) $order->getId()); + $handler($message); + // The pay transition can fire twice (winzou + workflow, redeliveries) — a no-op + $handler($message); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $account = $accountProvider->getByCustomerAndChannel($customer, $channel); + + self::assertSame(100, $account->getBalance()); + self::assertSame(100, $account->getLifetimeEarned()); + + $transactionRepository = $container->get(LoyaltyTransactionRepositoryInterface::class); + \assert($transactionRepository instanceof LoyaltyTransactionRepositoryInterface); + + $transactions = $transactionRepository->findForReplay($account); + self::assertCount(1, $transactions); + + $earn = $transactionRepository->findEarnOrderTransaction($order); + self::assertNotNull($earn); + self::assertSame(100, $earn->getPoints()); + self::assertSame(10000, $earn->getBasisAmount()); + self::assertNotNull($earn->getExpiresAt()); + } + + /** + * @test + */ + public function it_awards_nothing_for_an_unpaid_order(): void + { + $container = self::getContainer(); + + $channel = $this->channel(); + $this->rule($channel, points: 1, perAmount: 100); + + $customer = $this->customer(); + $order = $this->paidOrder($channel, $customer, unitPrice: 5000, quantity: 1); + $order->setPaymentState(OrderPaymentStates::STATE_AWAITING_PAYMENT); + $this->entityManager()->flush(); + + $handler = $container->get(AwardOrderPointsHandler::class); + \assert($handler instanceof AwardOrderPointsHandler); + $handler(new AwardOrderPoints((int) $order->getId())); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + + self::assertSame(0, $accountProvider->getByCustomerAndChannel($customer, $channel)->getBalance()); + } +} diff --git a/tests/Functional/Earning/AwardOrderPointsTestCase.php b/tests/Functional/Earning/AwardOrderPointsTestCase.php new file mode 100644 index 0000000..0980ca8 --- /dev/null +++ b/tests/Functional/Earning/AwardOrderPointsTestCase.php @@ -0,0 +1,125 @@ +entityManager(); + + $currency = $entityManager->getRepository(\Sylius\Component\Currency\Model\Currency::class)->findOneBy([]); + \assert($currency instanceof CurrencyInterface); + $locale = $entityManager->getRepository(\Sylius\Component\Locale\Model\Locale::class)->findOneBy([]); + \assert($locale instanceof LocaleInterface); + + $channelFactory = $container->get('sylius.factory.channel'); + \assert(is_object($channelFactory) && method_exists($channelFactory, 'createNew')); + $channel = $channelFactory->createNew(); + \assert($channel instanceof ChannelInterface); + + $channel->setCode(sprintf('LOYALTY_TEST_%s', uniqid())); + $channel->setName('Loyalty test channel'); + $channel->setTaxCalculationStrategy('order_items_based'); + $channel->setBaseCurrency($currency); + $channel->setDefaultLocale($locale); + $channel->addCurrency($currency); + $channel->addLocale($locale); + $channel->setEnabled(true); + + $entityManager->persist($channel); + $entityManager->flush(); + + return $channel; + } + + protected function rule(ChannelInterface $channel, int $points, int $perAmount): void + { + $container = self::getContainer(); + + $ruleFactory = $container->get('setono_sylius_loyalty.factory.earning_rule'); + \assert(is_object($ruleFactory) && method_exists($ruleFactory, 'createNew')); + $rule = $ruleFactory->createNew(); + \assert($rule instanceof EarningRuleInterface); + + $rule->setName('Functional base rate'); + $rule->setChannel($channel); + $rule->setAmountType('per_amount'); + $rule->setAmountConfiguration(['points' => $points, 'per_amount' => $perAmount]); + $rule->setEnabled(true); + + $this->entityManager()->persist($rule); + $this->entityManager()->flush(); + } + + protected function customer(): CustomerInterface + { + $customerFactory = self::getContainer()->get('sylius.factory.customer'); + \assert(is_object($customerFactory) && method_exists($customerFactory, 'createNew')); + + $customer = $customerFactory->createNew(); + \assert($customer instanceof CustomerInterface); + $customer->setEmail(sprintf('earning-%s@example.com', uniqid())); + + $this->entityManager()->persist($customer); + $this->entityManager()->flush(); + + return $customer; + } + + protected function paidOrder(ChannelInterface $channel, CustomerInterface $customer, int $unitPrice, int $quantity): Order + { + $container = self::getContainer(); + $entityManager = $this->entityManager(); + + $variant = $entityManager->getRepository(\Sylius\Component\Core\Model\ProductVariant::class)->findOneBy([]); + \assert($variant instanceof ProductVariantInterface); + + $orderFactory = $container->get('sylius.factory.order'); + \assert(is_object($orderFactory) && method_exists($orderFactory, 'createNew')); + $order = $orderFactory->createNew(); + \assert($order instanceof Order); + + $order->setChannel($channel); + $order->setCustomer($customer); + $order->setCurrencyCode((string) $channel->getBaseCurrency()?->getCode()); + $order->setLocaleCode((string) $channel->getDefaultLocale()?->getCode()); + + $orderItemFactory = $container->get('sylius.factory.order_item'); + \assert(is_object($orderItemFactory) && method_exists($orderItemFactory, 'createNew')); + $item = $orderItemFactory->createNew(); + \assert($item instanceof OrderItemInterface); + $item->setVariant($variant); + + $quantityModifier = $container->get('sylius.order_item_quantity_modifier'); + \assert($quantityModifier instanceof OrderItemQuantityModifierInterface); + $quantityModifier->modify($item, $quantity); + $item->setUnitPrice($unitPrice); + + $order->addItem($item); + $order->setCheckoutState(OrderCheckoutStates::STATE_COMPLETED); + $order->setState(Order::STATE_NEW); + $order->setPaymentState(OrderPaymentStates::STATE_PAID); + + $entityManager->persist($order); + $entityManager->flush(); + + return $order; + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php new file mode 100644 index 0000000..9303f6b --- /dev/null +++ b/tests/Functional/FunctionalTestCase.php @@ -0,0 +1,27 @@ +get('doctrine.orm.entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $entityManager); + + return $entityManager; + } +} diff --git a/tests/Functional/Ledger/LoyaltyLedgerTest.php b/tests/Functional/Ledger/LoyaltyLedgerTest.php new file mode 100644 index 0000000..760c0cf --- /dev/null +++ b/tests/Functional/Ledger/LoyaltyLedgerTest.php @@ -0,0 +1,128 @@ +account(); + $ledger = $this->ledger(); + + $first = $ledger->earnAction($account, 100, 'functional-test:1'); + + self::assertNotNull($first); + self::assertSame(100, $first->getPoints()); + self::assertSame(100, $account->getBalance()); + self::assertSame(100, $account->getLifetimeEarned()); + + // Redelivery of the same action must be a no-op thanks to the unique constraint + $second = $this->ledger()->earnAction($this->reloadAccount($account), 100, 'functional-test:1'); + + self::assertNull($second); + self::assertSame(100, $this->reloadAccount($account)->getBalance()); + } + + /** + * @test + */ + public function it_records_manual_adjustments_and_keeps_the_balance_in_sync_with_the_ledger(): void + { + $account = $this->account(); + $ledger = $this->ledger(); + + $ledger->manualCredit($account, 500, 'goodwill', 'Functional test credit'); + $ledger->manualDebit($this->reloadAccount($account), 200, 'correction', 'Functional test debit'); + + $account = $this->reloadAccount($account); + self::assertSame(300, $account->getBalance()); + self::assertSame(500, $account->getLifetimeEarned()); + + $transactionRepository = self::getContainer()->get(LoyaltyTransactionRepositoryInterface::class); + \assert($transactionRepository instanceof LoyaltyTransactionRepositoryInterface); + + self::assertSame(300, $transactionRepository->sumPoints($account)); + self::assertCount(2, $transactionRepository->findForReplay($account)); + } + + /** + * @test + */ + public function it_expires_lots_past_their_expiry_and_replays_are_noops(): void + { + $account = $this->account(); + $ledger = $this->ledger(); + + // One lot expired yesterday, one never expires + $expired = $ledger->earnAction($account, 100, 'expiry-test:expired', [], new \DateTimeImmutable('-1 day')); + $ledger->earnAction($this->reloadAccount($account), 50, 'expiry-test:open'); + + self::assertNotNull($expired); + self::assertSame(150, $this->reloadAccount($account)->getBalance()); + + $entries = $ledger->expire($this->reloadAccount($account)); + + self::assertCount(1, $entries); + self::assertSame(-100, $entries[0]->getPoints()); + self::assertSame($expired->getId(), $entries[0]->getLot()?->getId()); + self::assertSame(50, $this->reloadAccount($account)->getBalance()); + // Expiring never touches lifetime earned + self::assertSame(150, $this->reloadAccount($account)->getLifetimeEarned()); + + // The lot is closed - a second run writes nothing + self::assertCount(0, $ledger->expire($this->reloadAccount($account))); + } + + private function account(): LoyaltyAccountInterface + { + $container = self::getContainer(); + + $customerFactory = $container->get('sylius.factory.customer'); + \assert(is_object($customerFactory) && method_exists($customerFactory, 'createNew')); + + $customer = $customerFactory->createNew(); + \assert($customer instanceof CustomerInterface); + $customer->setEmail(sprintf('loyalty-%s@example.com', uniqid())); + + $entityManager = $this->entityManager(); + $entityManager->persist($customer); + $entityManager->flush(); + + $channel = $entityManager->getRepository(\Sylius\Component\Core\Model\Channel::class)->findOneBy([]); + \assert($channel instanceof ChannelInterface); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + + return $accountProvider->getByCustomerAndChannel($customer, $channel); + } + + private function ledger(): LoyaltyLedgerInterface + { + $ledger = self::getContainer()->get(LoyaltyLedgerInterface::class); + \assert($ledger instanceof LoyaltyLedgerInterface); + + return $ledger; + } + + private function reloadAccount(LoyaltyAccountInterface $account): LoyaltyAccountInterface + { + $reloaded = $this->entityManager()->find($account::class, $account->getId()); + \assert($reloaded instanceof LoyaltyAccountInterface); + + return $reloaded; + } +} diff --git a/tests/Functional/Redemption/ConcurrentRedemptionTest.php b/tests/Functional/Redemption/ConcurrentRedemptionTest.php new file mode 100644 index 0000000..3e832e8 --- /dev/null +++ b/tests/Functional/Redemption/ConcurrentRedemptionTest.php @@ -0,0 +1,162 @@ +customer) { + $entityManager = $this->entityManager(); + $customer = $entityManager->find($this->customer::class, $this->customer->getId()); + if (null !== $customer) { + foreach ($entityManager->getRepository(Order::class)->findBy(['customer' => $customer]) as $order) { + $entityManager->remove($order); + } + $entityManager->remove($customer); + $entityManager->flush(); + } + } + + parent::tearDown(); + } + + /** + * @test + */ + public function it_never_overspends_a_balance_under_concurrent_redemptions(): void + { + $container = self::getContainer(); + $entityManager = $this->entityManager(); + + $channel = $entityManager->getRepository(\Sylius\Component\Core\Model\Channel::class)->findOneByCode('FASHION_WEB'); + \assert($channel instanceof ChannelInterface); + + $this->customer = $this->customer(); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $account = $accountProvider->getByCustomerAndChannel($this->customer, $channel); + + $ledger = $container->get(LoyaltyLedgerInterface::class); + \assert($ledger instanceof LoyaltyLedgerInterface); + $ledger->manualCredit($account, 500, 'goodwill', 'Concurrency seed'); + + $firstOrder = $this->order($channel, $this->customer); + $secondOrder = $this->order($channel, $this->customer); + + $script = dirname(__DIR__) . '/scripts/concurrent-redeem.php'; + $first = new Process([\PHP_BINARY, $script, (string) $firstOrder->getId(), '400']); + $second = new Process([\PHP_BINARY, $script, (string) $secondOrder->getId(), '400']); + + $first->start(); + $second->start(); + $first->wait(); + $second->wait(); + + $outputs = [trim($first->getOutput()), trim($second->getOutput())]; + sort($outputs); + + self::assertSame(['INSUFFICIENT', 'OK'], $outputs, sprintf( + 'Expected exactly one successful redemption, got [%s] / [%s] (stderr: %s / %s)', + $first->getOutput(), + $second->getOutput(), + $first->getErrorOutput(), + $second->getErrorOutput(), + )); + + $entityManager->clear(); + $reloaded = $entityManager->find($account::class, $account->getId()); + \assert($reloaded instanceof LoyaltyAccountInterface); + + self::assertSame(100, $reloaded->getBalance()); + self::assertGreaterThanOrEqual(0, $reloaded->getBalance()); + } + + private function customer(): CustomerInterface + { + $customerFactory = self::getContainer()->get('sylius.factory.customer'); + \assert(is_object($customerFactory) && method_exists($customerFactory, 'createNew')); + + $customer = $customerFactory->createNew(); + \assert($customer instanceof CustomerInterface); + $customer->setEmail(sprintf('concurrency-%s@example.com', uniqid())); + + $this->entityManager()->persist($customer); + $this->entityManager()->flush(); + + return $customer; + } + + private function order(ChannelInterface $channel, CustomerInterface $customer): Order + { + $container = self::getContainer(); + $entityManager = $this->entityManager(); + + $variant = $entityManager->getRepository(\Sylius\Component\Core\Model\ProductVariant::class)->findOneBy([]); + \assert($variant instanceof ProductVariantInterface); + + $orderFactory = $container->get('sylius.factory.order'); + \assert(is_object($orderFactory) && method_exists($orderFactory, 'createNew')); + $order = $orderFactory->createNew(); + \assert($order instanceof Order); + + $order->setChannel($channel); + $order->setCustomer($customer); + $order->setCurrencyCode((string) $channel->getBaseCurrency()?->getCode()); + $order->setLocaleCode((string) $channel->getDefaultLocale()?->getCode()); + + $orderItemFactory = $container->get('sylius.factory.order_item'); + \assert(is_object($orderItemFactory) && method_exists($orderItemFactory, 'createNew')); + $item = $orderItemFactory->createNew(); + \assert($item instanceof OrderItemInterface); + $item->setVariant($variant); + + $quantityModifier = $container->get('sylius.order_item_quantity_modifier'); + \assert($quantityModifier instanceof OrderItemQuantityModifierInterface); + $quantityModifier->modify($item, 1); + $item->setUnitPrice(100000); + + $order->addItem($item); + + $entityManager->persist($order); + $entityManager->flush(); + + return $order; + } +} diff --git a/tests/Functional/Redemption/RedemptionTest.php b/tests/Functional/Redemption/RedemptionTest.php new file mode 100644 index 0000000..404828e --- /dev/null +++ b/tests/Functional/Redemption/RedemptionTest.php @@ -0,0 +1,139 @@ +entityManager(); + + // An isolated channel with an explicitly priced variant: no fixture promotions or tax + // rates can shift the totals, so the clamping math is exact on every Sylius version + $channel = $this->channel(); + $variant = $entityManager->getRepository(\Sylius\Component\Core\Model\ProductVariant::class)->findOneBy([]); + \assert($variant instanceof ProductVariantInterface); + + $channelPricing = new \Sylius\Component\Core\Model\ChannelPricing(); + $channelPricing->setChannelCode($channel->getCode()); + $channelPricing->setPrice(950); + $variant->addChannelPricing($channelPricing); + $entityManager->persist($channelPricing); + $entityManager->flush(); + + // A customer with 1000 points + $customer = $this->customer(); + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $account = $accountProvider->getByCustomerAndChannel($customer, $channel); + $ledger = $container->get(LoyaltyLedgerInterface::class); + \assert($ledger instanceof LoyaltyLedgerInterface); + $ledger->manualCredit($account, 1000, 'goodwill', 'Redemption test seed'); + + $order = $this->cart($channel, $customer, $variant); + + $processor = $container->get('sylius.order_processing.order_processor'); + \assert($processor instanceof OrderProcessorInterface); + + // A huge "Use max"-style request + $order->setLoyaltyPointsRequested(100000); + $processor->process($order); + + // Default program: conversion 1pt = 1 minor unit, cap 50% of the 19.00 items total + // (2 x 9.50) — the cap binds before the 1000-point balance does + $expectedApplied = 950; + + self::assertGreaterThan(0, $expectedApplied); + self::assertSame( + -$expectedApplied, + $order->getAdjustmentsTotalRecursively(LoyaltyAdjustmentTypes::REDEMPTION), + ); + + $appliedPointsProvider = $container->get(AppliedPointsProviderInterface::class); + \assert($appliedPointsProvider instanceof AppliedPointsProviderInterface); + self::assertSame($expectedApplied, $appliedPointsProvider->getAppliedPoints($order)); + + // The stored request is never overwritten by clamping + self::assertSame(100000, $order->getLoyaltyPointsRequested()); + + $entityManager->persist($order); + $entityManager->flush(); + + // The completion debit uses the applied points, never the raw request; replays no-op + $debit = $ledger->redeem($order, $expectedApplied); + self::assertNotNull($debit); + self::assertSame(-$expectedApplied, $debit->getPoints()); + self::assertSame(1000 - $expectedApplied, $this->reloadAccount($account)->getBalance()); + + self::assertNull($ledger->redeem($order, $expectedApplied)); + + // Cancelling restores the exact number of points as a new lot + $rollback = $ledger->rollbackRedeem($order); + self::assertNotNull($rollback); + self::assertSame($expectedApplied, $rollback->getPoints()); + self::assertSame(1000, $this->reloadAccount($account)->getBalance()); + // Restored points were already counted when earned + self::assertSame(1000, $this->reloadAccount($account)->getLifetimeEarned()); + + self::assertNull($ledger->rollbackRedeem($order)); + } + + private function cart(ChannelInterface $channel, CustomerInterface $customer, ProductVariantInterface $variant): Order + { + $container = self::getContainer(); + + $orderFactory = $container->get('sylius.factory.order'); + \assert(is_object($orderFactory) && method_exists($orderFactory, 'createNew')); + $order = $orderFactory->createNew(); + \assert($order instanceof Order); + + $order->setChannel($channel); + $order->setCustomer($customer); + $order->setCurrencyCode((string) $channel->getBaseCurrency()?->getCode()); + $order->setLocaleCode((string) $channel->getDefaultLocale()?->getCode()); + + $orderItemFactory = $container->get('sylius.factory.order_item'); + \assert(is_object($orderItemFactory) && method_exists($orderItemFactory, 'createNew')); + $item = $orderItemFactory->createNew(); + \assert($item instanceof OrderItemInterface); + $item->setVariant($variant); + + $quantityModifier = $container->get('sylius.order_item_quantity_modifier'); + \assert($quantityModifier instanceof OrderItemQuantityModifierInterface); + $quantityModifier->modify($item, 2); + + $order->addItem($item); + \assert($order->getState() === OrderInterface::STATE_CART); + + return $order; + } + + private function reloadAccount(LoyaltyAccountInterface $account): LoyaltyAccountInterface + { + $reloaded = $this->entityManager()->find($account::class, $account->getId()); + \assert($reloaded instanceof LoyaltyAccountInterface); + + return $reloaded; + } +} diff --git a/tests/Functional/Referral/ReferralLifecycleTest.php b/tests/Functional/Referral/ReferralLifecycleTest.php new file mode 100644 index 0000000..569097b --- /dev/null +++ b/tests/Functional/Referral/ReferralLifecycleTest.php @@ -0,0 +1,169 @@ +entityManager(); + + $channel = $this->channel(); + $referrer = $this->customer(); + $referee = $this->customer(); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $referrerAccount = $accountProvider->getByCustomerAndChannel($referrer, $channel); + + $referral = $this->referral($referrerAccount, $referee, $channel); + + // The qualifying order: above the default 25.00 minimum + $order = $this->paidOrder($channel, $referee, unitPrice: 5000, quantity: 1); + $order->setCheckoutCompletedAt(new \DateTimeImmutable('+1 minute')); + $entityManager->flush(); + + $qualifier = $container->get(ReferralQualifierInterface::class); + \assert($qualifier instanceof ReferralQualifierInterface); + + $qualifier->qualify($order); + // A duplicate call (redelivery) must not double-reward + $qualifier->qualify($order); + + $programProvider = $container->get(LoyaltyProgramProviderInterface::class); + \assert($programProvider instanceof LoyaltyProgramProviderInterface); + $program = $programProvider->getByChannel($channel); + + self::assertSame(ReferralInterface::STATUS_REWARDED, $referral->getStatus()); + self::assertSame($program->getReferralReferrerPoints(), $referrerAccount->getBalance()); + + $refereeAccount = $accountProvider->getByCustomerAndChannel($referee, $channel); + self::assertSame($program->getReferralRefereePoints(), $refereeAccount->getBalance()); + + // Cancelling the qualifying order claws back BOTH rewards + $clawbackListener = $container->get(ClawbackListener::class); + \assert($clawbackListener instanceof ClawbackListener); + $clawbackListener->clawback($order); + $clawbackListener->clawback($order); // idempotent + + $entityManager->refresh($referrerAccount); + $entityManager->refresh($refereeAccount); + self::assertSame(0, $referrerAccount->getBalance()); + self::assertSame(0, $refereeAccount->getBalance()); + } + + /** + * @test + */ + public function it_rejects_a_self_referral_by_normalized_email(): void + { + $container = self::getContainer(); + $entityManager = $this->entityManager(); + + $channel = $this->channel(); + + $referrer = $this->customer(); + $referrerEmail = (string) $referrer->getEmail(); + + // Same mailbox with Gmail-style aliasing + $referee = $this->customer(); + [$local, $domain] = explode('@', $referrerEmail, 2); + $referee->setEmail(sprintf('%s+friend@%s', $local, $domain)); + $entityManager->flush(); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $referrerAccount = $accountProvider->getByCustomerAndChannel($referrer, $channel); + + $referral = $this->referral($referrerAccount, $referee, $channel); + + $order = $this->paidOrder($channel, $referee, unitPrice: 5000, quantity: 1); + $order->setCheckoutCompletedAt(new \DateTimeImmutable('+1 minute')); + $entityManager->flush(); + + $qualifier = $container->get(ReferralQualifierInterface::class); + \assert($qualifier instanceof ReferralQualifierInterface); + $qualifier->qualify($order); + + self::assertSame(ReferralInterface::STATUS_REJECTED, $referral->getStatus()); + self::assertSame('self_referral', $referral->getFraudFlags()[0]['check'] ?? null); + self::assertSame(0, $referrerAccount->getBalance()); + } + + /** + * @test + */ + public function it_does_not_qualify_an_order_below_the_minimum(): void + { + $container = self::getContainer(); + $entityManager = $this->entityManager(); + + $channel = $this->channel(); + $referrer = $this->customer(); + $referee = $this->customer(); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $referrerAccount = $accountProvider->getByCustomerAndChannel($referrer, $channel); + + $referral = $this->referral($referrerAccount, $referee, $channel); + + // Below the default 25.00 minimum + $order = $this->paidOrder($channel, $referee, unitPrice: 1000, quantity: 1); + $order->setCheckoutCompletedAt(new \DateTimeImmutable('+1 minute')); + $entityManager->flush(); + + $qualifier = $container->get(ReferralQualifierInterface::class); + \assert($qualifier instanceof ReferralQualifierInterface); + $qualifier->qualify($order); + + self::assertSame(ReferralInterface::STATUS_PENDING, $referral->getStatus()); + self::assertSame($order->getId(), $referral->getRefereeFirstOrder()?->getId()); + + // A later, larger order does not re-open the decision + $secondOrder = $this->paidOrder($channel, $referee, unitPrice: 9000, quantity: 1); + $secondOrder->setCheckoutCompletedAt(new \DateTimeImmutable('+2 minutes')); + $entityManager->flush(); + $qualifier->qualify($secondOrder); + + self::assertSame(ReferralInterface::STATUS_PENDING, $referral->getStatus()); + self::assertSame(0, $referrerAccount->getBalance()); + } + + private function referral(LoyaltyAccountInterface $referrerAccount, CustomerInterface $referee, ChannelInterface $channel): ReferralInterface + { + $container = self::getContainer(); + + $factory = $container->get('setono_sylius_loyalty.factory.referral'); + \assert(is_object($factory) && method_exists($factory, 'createNew')); + $referral = $factory->createNew(); + \assert($referral instanceof ReferralInterface); + + $referral->setReferrerAccount($referrerAccount); + $referral->setRefereeCustomer($referee); + $referral->setChannel($channel); + $referral->setCode('TESTCODE'); + + $this->entityManager()->persist($referral); + $this->entityManager()->flush(); + + return $referral; + } +} diff --git a/tests/Functional/Tier/InlineTierUpgradeTest.php b/tests/Functional/Tier/InlineTierUpgradeTest.php new file mode 100644 index 0000000..d283a47 --- /dev/null +++ b/tests/Functional/Tier/InlineTierUpgradeTest.php @@ -0,0 +1,58 @@ +entityManager(); + + $channel = $this->channel(); + + $tierFactory = $container->get('setono_sylius_loyalty.factory.tier'); + \assert(is_object($tierFactory) && method_exists($tierFactory, 'createNew')); + $tier = $tierFactory->createNew(); + \assert($tier instanceof TierInterface); + $tier->setCode('func_silver'); + $tier->setName('Silver'); + $tier->setChannel($channel); + $tier->setPosition(1); + $tier->setThreshold(1000); + $entityManager->persist($tier); + $entityManager->flush(); + + $customer = $this->customer(); + + $accountProvider = $container->get(LoyaltyAccountProviderInterface::class); + \assert($accountProvider instanceof LoyaltyAccountProviderInterface); + $account = $accountProvider->getByCustomerAndChannel($customer, $channel); + + $ledger = $container->get(LoyaltyLedgerInterface::class); + \assert($ledger instanceof LoyaltyLedgerInterface); + + // Below the threshold: no tier + $ledger->earnAction($account, 500, 'inline-tier:first'); + self::assertNull($account->getTier()); + + // Crossing the threshold upgrades within the same call + $ledger->earnAction($account, 700, 'inline-tier:second'); + + self::assertNotNull($account->getTier()); + self::assertSame('func_silver', $account->getTier()->getCode()); + } +} diff --git a/tests/Functional/scripts/concurrent-redeem.php b/tests/Functional/scripts/concurrent-redeem.php new file mode 100644 index 0000000..7014c3e --- /dev/null +++ b/tests/Functional/scripts/concurrent-redeem.php @@ -0,0 +1,48 @@ +boot(); +$container = $kernel->getContainer()->get('test.service_container'); +assert($container instanceof \Symfony\Component\DependencyInjection\ContainerInterface); + +$entityManager = $container->get('doctrine.orm.entity_manager'); +assert($entityManager instanceof \Doctrine\ORM\EntityManagerInterface); + +$orderClass = $container->getParameter('sylius.model.order.class'); +assert(is_string($orderClass) && class_exists($orderClass)); + +$order = $entityManager->find($orderClass, $orderId); + +if (!$order instanceof OrderInterface) { + echo 'ORDER_NOT_FOUND'; + exit(2); +} + +$ledger = $container->get(LoyaltyLedgerInterface::class); +assert($ledger instanceof LoyaltyLedgerInterface); + +try { + $transaction = $ledger->redeem($order, $points); + echo null === $transaction ? 'NOOP' : 'OK'; +} catch (InsufficientBalanceException) { + echo 'INSUFFICIENT'; +} diff --git a/tests/PHPStan/console_application.php b/tests/PHPStan/console_application.php index 42084e6..666bd5d 100644 --- a/tests/PHPStan/console_application.php +++ b/tests/PHPStan/console_application.php @@ -5,7 +5,9 @@ use Setono\SyliusLoyaltyPlugin\Tests\Application\Kernel; use Symfony\Bundle\FrameworkBundle\Console\Application; -require __DIR__ . '/../../vendor/autoload.php'; +$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = 'test'; + +require __DIR__ . '/../Application/config/bootstrap.php'; $kernel = new Kernel('test', true); $kernel->boot(); diff --git a/tests/PHPStan/object_manager.php b/tests/PHPStan/object_manager.php index e9037fc..f29e3e4 100644 --- a/tests/PHPStan/object_manager.php +++ b/tests/PHPStan/object_manager.php @@ -4,7 +4,9 @@ use Setono\SyliusLoyaltyPlugin\Tests\Application\Kernel; -require __DIR__ . '/../../vendor/autoload.php'; +$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = 'test'; + +require __DIR__ . '/../Application/config/bootstrap.php'; $kernel = new Kernel('test', true); $kernel->boot(); diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/DependencyInjection/Compiler/RegisterEarningTriggersPassTest.php b/tests/Unit/DependencyInjection/Compiler/RegisterEarningTriggersPassTest.php new file mode 100644 index 0000000..7df5854 --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/RegisterEarningTriggersPassTest.php @@ -0,0 +1,112 @@ +process($container); + + /** @var array}> $catalog */ + $catalog = $container->getParameter('setono_sylius_loyalty.trigger_catalog'); + + self::assertArrayHasKey('customer_registered', $catalog); + self::assertArrayHasKey('newsletter_subscribed', $catalog); + self::assertSame(['source' => 'string'], $catalog['newsletter_subscribed']['context']); + + $tags = $container->getDefinition(EarningTriggerListener::class)->getTag('kernel.event_listener'); + $events = array_column($tags, 'event'); + self::assertContains(CustomerRegisteredTriggerEvent::class, $events); + self::assertContains(NewsletterSubscribedTriggerEvent::class, $events); + } + + /** + * @test + */ + public function it_rejects_classes_not_extending_the_trigger_base(): void + { + $container = self::container([\stdClass::class]); + + $this->expectException(InvalidTriggerException::class); + + (new RegisterEarningTriggersPass())->process($container); + } + + /** + * @test + */ + public function it_rejects_colliding_trigger_codes(): void + { + $container = self::container([CollidingTriggerEvent::class]); + + $this->expectException(InvalidTriggerException::class); + $this->expectExceptionMessageMatches('/collides/'); + + (new RegisterEarningTriggersPass())->process($container); + } + + /** + * @param list $triggers + */ + private static function container(array $triggers): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('setono_sylius_loyalty.triggers', $triggers); + $container->setDefinition(EarningTriggerListener::class, new Definition(EarningTriggerListener::class)); + + return $container; + } +} + +final class NewsletterSubscribedTriggerEvent extends EarningTriggerEvent +{ + public function __construct( + CustomerInterface $customer, + public readonly string $source, + ?ChannelInterface $channel = null, + ) { + parent::__construct($customer, $channel); + } + + public static function getCode(): string + { + return 'newsletter_subscribed'; + } + + public static function getLabel(): string + { + return 'app.trigger.newsletter_subscribed'; + } +} + +final class CollidingTriggerEvent extends EarningTriggerEvent +{ + public static function getCode(): string + { + return 'customer_registered'; + } + + public static function getLabel(): string + { + return 'app.trigger.colliding'; + } +} diff --git a/tests/Unit/EarningRule/Basis/EligibleBasisCalculatorTest.php b/tests/Unit/EarningRule/Basis/EligibleBasisCalculatorTest.php new file mode 100644 index 0000000..d03800e --- /dev/null +++ b/tests/Unit/EarningRule/Basis/EligibleBasisCalculatorTest.php @@ -0,0 +1,89 @@ +order([1 => [5000, 1000], 2 => [3000, 600]], orderTotal: 9000, orderTaxTotal: 1600); + + $basis = (new EligibleBasisCalculator())->calculate($order, new LoyaltyProgram()); + + self::assertSame([1 => 4000, 2 => 2400], $basis->itemAmounts); + self::assertSame(0, $basis->extraAmount); + self::assertSame(6400, $basis->getTotal()); + } + + /** + * @test + */ + public function it_includes_taxes_when_configured(): void + { + $order = $this->order([1 => [5000, 1000]], orderTotal: 5000, orderTaxTotal: 1000); + + $program = new LoyaltyProgram(); + $program->setIncludeTaxes(true); + + $basis = (new EligibleBasisCalculator())->calculate($order, $program); + + self::assertSame([1 => 5000], $basis->itemAmounts); + } + + /** + * @test + */ + public function it_adds_the_non_item_remainder_under_the_order_total_basis(): void + { + // 80.00 of items (no taxes) + 15.00 shipping = 95.00 order total + $order = $this->order([1 => [8000, 0]], orderTotal: 9500, orderTaxTotal: 0); + + $program = new LoyaltyProgram(); + $program->setEarningBasis(LoyaltyProgramInterface::EARNING_BASIS_ORDER_TOTAL); + + $basis = (new EligibleBasisCalculator())->calculate($order, $program); + + self::assertSame([1 => 8000], $basis->itemAmounts); + self::assertSame(1500, $basis->extraAmount); + self::assertSame(9500, $basis->getTotal()); + } + + /** + * @param array $items item id => [total, taxTotal] + */ + private function order(array $items, int $orderTotal, int $orderTaxTotal): OrderInterface + { + $itemProphecies = []; + foreach ($items as $id => [$total, $taxTotal]) { + $item = $this->prophesize(OrderItemInterface::class); + $item->getId()->willReturn($id); + $item->getTotal()->willReturn($total); + $item->getTaxTotal()->willReturn($taxTotal); + $itemProphecies[] = $item->reveal(); + } + + $order = $this->prophesize(OrderInterface::class); + $order->getItems()->willReturn(new ArrayCollection($itemProphecies)); + $order->getTotal()->willReturn($orderTotal); + $order->getTaxTotal()->willReturn($orderTaxTotal); + + return $order->reveal(); + } +} diff --git a/tests/Unit/EarningRule/EarningRuleEvaluatorTest.php b/tests/Unit/EarningRule/EarningRuleEvaluatorTest.php new file mode 100644 index 0000000..2f6735a --- /dev/null +++ b/tests/Unit/EarningRule/EarningRuleEvaluatorTest.php @@ -0,0 +1,343 @@ +program = new LoyaltyProgram(); + } + + /** + * @test + */ + public function it_awards_per_amount_on_the_order_basis(): void + { + // Given a paid order of 100.00 with rule "1 pt / 1.00" the award is 100 points + $rule = self::perAmountRule(1, points: 1, perAmount: 100); + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(100, $result->points); + } + + /** + * @test + */ + public function it_floors_the_award_by_default(): void + { + // Given rounding = floor and basis 19.99 with 1pt/1.00, the award is 19 + $rule = self::perAmountRule(1, points: 1, perAmount: 100); + $context = $this->orderContext([1 => 1999]); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(19, $result->points); + } + + /** + * @test + */ + public function it_claims_items_exclusively_with_product_scope_beating_order_scope(): void + { + // Given order rule 1pt/1.00 and a product-scoped rule 3pt/1.00 for product X: an order + // with 40.00 of X and 60.00 of other items awards 3*40 + 1*60 = 180 points + $orderRule = self::perAmountRule(1, points: 1, perAmount: 100); + $productRule = self::perAmountRule(2, points: 3, perAmount: 100); + $productRule->setScope(EarningRuleInterface::SCOPE_PRODUCT); + $productRule->setScopeConfiguration(['products' => ['X']]); + + $context = $this->orderContext([1 => 4000, 2 => 6000], [1 => 'X', 2 => 'Y']); + + $result = $this->evaluator()->evaluate([$orderRule, $productRule], $context, $this->program); + + self::assertSame(180, $result->points); + } + + /** + * @test + */ + public function it_excludes_a_product_from_earning_via_a_zero_rate_scoped_rule(): void + { + $orderRule = self::perAmountRule(1, points: 1, perAmount: 100); + $zeroRule = self::perAmountRule(2, points: 0, perAmount: 100); + $zeroRule->setScope(EarningRuleInterface::SCOPE_PRODUCT); + $zeroRule->setScopeConfiguration(['products' => ['GIFT_CARD']]); + + $context = $this->orderContext([1 => 5000, 2 => 5000], [1 => 'GIFT_CARD', 2 => 'Y']); + + $result = $this->evaluator()->evaluate([$orderRule, $zeroRule], $context, $this->program); + + self::assertSame(50, $result->points); + } + + /** + * @test + */ + public function it_applies_the_highest_priority_non_stackable_rule_alone(): void + { + $stackable = self::perAmountRule(1, points: 1, perAmount: 100); + $nonStackableLow = self::perAmountRule(2, points: 2, perAmount: 100); + $nonStackableLow->setStackable(false); + $nonStackableHigh = self::perAmountRule(3, points: 5, perAmount: 100); + $nonStackableHigh->setStackable(false); + $nonStackableHigh->setPriority(10); + + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$stackable, $nonStackableLow, $nonStackableHigh], $context, $this->program); + + self::assertSame(500, $result->points); + } + + /** + * @test + */ + public function it_sums_stackable_rules_competing_for_the_same_basis(): void + { + $first = self::perAmountRule(1, points: 1, perAmount: 100); + $second = self::perAmountRule(2, points: 2, perAmount: 100); + + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$first, $second], $context, $this->program); + + self::assertSame(300, $result->points); + } + + /** + * @test + */ + public function it_applies_stackable_multipliers_cumulatively(): void + { + $base = self::perAmountRule(1, points: 1, perAmount: 100); + $double = self::multiplierRule(2, 2.0); + $triple = self::multiplierRule(3, 3.0); + + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$base, $double, $triple], $context, $this->program); + + self::assertSame(600, $result->points); + } + + /** + * @test + */ + public function it_applies_a_single_non_stackable_multiplier(): void + { + $base = self::perAmountRule(1, points: 1, perAmount: 100); + $stackable = self::multiplierRule(2, 2.0); + $nonStackable = self::multiplierRule(3, 5.0); + $nonStackable->setStackable(false); + $nonStackable->setPriority(10); + + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$base, $stackable, $nonStackable], $context, $this->program); + + self::assertSame(500, $result->points); + } + + /** + * @test + */ + public function it_matches_any_condition_when_configured(): void + { + $rule = self::perAmountRule(1, points: 1, perAmount: 100); + $rule->setConditionsMatch(EarningRuleInterface::CONDITIONS_MATCH_ANY); + $rule->addCondition(self::condition(DayOfWeekConditionChecker::TYPE, ['days' => [(int) (new \DateTimeImmutable('2026-07-06'))->format('N')]])); + $rule->addCondition(self::condition(DayOfWeekConditionChecker::TYPE, ['days' => [7]])); + + $context = $this->orderContext([1 => 10000], now: new \DateTimeImmutable('2026-07-06 12:00')); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(100, $result->points); + } + + /** + * @test + */ + public function it_requires_all_conditions_by_default(): void + { + $rule = self::perAmountRule(1, points: 1, perAmount: 100); + $rule->addCondition(self::condition(DayOfWeekConditionChecker::TYPE, ['days' => [(int) (new \DateTimeImmutable('2026-07-06'))->format('N')]])); + $rule->addCondition(self::condition(DayOfWeekConditionChecker::TYPE, ['days' => [7]])); + + $context = $this->orderContext([1 => 10000], now: new \DateTimeImmutable('2026-07-06 12:00')); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(0, $result->points); + self::assertFalse($result->ruleEvaluations[0]->matched); + } + + /** + * @test + */ + public function it_diverts_dry_run_rules_from_the_live_result(): void + { + $live = self::perAmountRule(1, points: 1, perAmount: 100); + $dryRun = self::perAmountRule(2, points: 10, perAmount: 100); + $dryRun->setDryRun(true); + + $context = $this->orderContext([1 => 10000]); + + $result = $this->evaluator()->evaluate([$live, $dryRun], $context, $this->program); + + self::assertSame(100, $result->points); + self::assertCount(1, $result->dryRunEvaluations); + self::assertSame(1000.0, $result->dryRunEvaluations[0]->points); + } + + /** + * @test + */ + public function it_ignores_rules_outside_their_window(): void + { + $rule = self::perAmountRule(1, points: 1, perAmount: 100); + $rule->setStartsAt(new \DateTimeImmutable('2026-11-27')); + + $context = $this->orderContext([1 => 10000], now: new \DateTimeImmutable('2026-07-06')); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(0, $result->points); + self::assertSame([], $result->ruleEvaluations); + } + + /** + * @test + */ + public function it_awards_fixed_points_per_matching_unit_under_item_scopes(): void + { + $rule = self::rule(1, FixedAmountCalculator::TYPE, ['points' => 10]); + $rule->setScope(EarningRuleInterface::SCOPE_PRODUCT); + $rule->setScopeConfiguration(['products' => ['X']]); + + // Item 1 has quantity 3 + $context = $this->orderContext([1 => 6000], [1 => 'X'], quantities: [1 => 3]); + + $result = $this->evaluator()->evaluate([$rule], $context, $this->program); + + self::assertSame(30, $result->points); + } + + private function evaluator(): EarningRuleEvaluator + { + $conditionCheckers = new ConditionCheckerRegistry(); + $conditionCheckers->add(new DayOfWeekConditionChecker()); + + $amountCalculators = new AmountCalculatorRegistry(); + $amountCalculators->add(new FixedAmountCalculator()); + $amountCalculators->add(new PerAmountCalculator()); + $amountCalculators->add(new MultiplierAmountCalculator()); + + return new EarningRuleEvaluator($conditionCheckers, $amountCalculators, new NullLogger()); + } + + /** + * @param array $itemAmounts + * @param array $productCodes item id => product code + * @param array $quantities item id => quantity + */ + private function orderContext( + array $itemAmounts, + array $productCodes = [], + ?\DateTimeImmutable $now = null, + array $quantities = [], + ): EarningContext { + $channel = $this->prophesize(ChannelInterface::class)->reveal(); + + $items = []; + foreach (array_keys($itemAmounts) as $itemId) { + $product = $this->prophesize(ProductInterface::class); + $product->getCode()->willReturn($productCodes[$itemId] ?? sprintf('PRODUCT_%d', $itemId)); + $product->getTaxons()->willReturn(new ArrayCollection()); + + $item = $this->prophesize(OrderItemInterface::class); + $item->getId()->willReturn($itemId); + $item->getProduct()->willReturn($product->reveal()); + $item->getQuantity()->willReturn($quantities[$itemId] ?? 1); + + $items[] = $item->reveal(); + } + + $order = $this->prophesize(OrderInterface::class); + $order->getItems()->willReturn(new ArrayCollection($items)); + + return new EarningContext( + channel: $channel, + order: $order->reveal(), + itemAmounts: $itemAmounts, + now: $now, + ); + } + + /** + * @param array $amountConfiguration + */ + private static function rule(int $id, string $amountType, array $amountConfiguration): EarningRule + { + $rule = new EarningRule(); + $rule->setAmountType($amountType); + $rule->setAmountConfiguration($amountConfiguration); + + $reflection = new \ReflectionProperty(EarningRule::class, 'id'); + $reflection->setValue($rule, $id); + + return $rule; + } + + private static function perAmountRule(int $id, int $points, int $perAmount): EarningRule + { + return self::rule($id, PerAmountCalculator::TYPE, ['points' => $points, 'per_amount' => $perAmount]); + } + + private static function multiplierRule(int $id, float $factor): EarningRule + { + return self::rule($id, MultiplierAmountCalculator::TYPE, ['factor' => $factor]); + } + + /** + * @param array $configuration + */ + private static function condition(string $type, array $configuration): EarningRuleCondition + { + $condition = new EarningRuleCondition(); + $condition->setType($type); + $condition->setConfiguration($configuration); + + return $condition; + } +} diff --git a/tests/Unit/EventListener/Doctrine/DiscriminatorMapListenerTest.php b/tests/Unit/EventListener/Doctrine/DiscriminatorMapListenerTest.php new file mode 100644 index 0000000..139b682 --- /dev/null +++ b/tests/Unit/EventListener/Doctrine/DiscriminatorMapListenerTest.php @@ -0,0 +1,82 @@ +setDiscriminatorMap(['earn_order' => EarnOrderLoyaltyTransaction::class]); + + $listener = new DiscriminatorMapListener(LoyaltyTransaction::class, [ + 'app.badge_transaction' => ['classes' => ['model' => ManualCreditLoyaltyTransaction::class]], + 'sylius.product' => ['classes' => ['model' => LoyaltyAccount::class]], + ]); + $listener->loadClassMetadata($this->eventArgs($metadata)); + + self::assertSame([ + 'earn_order' => EarnOrderLoyaltyTransaction::class, + 'manual_credit' => ManualCreditLoyaltyTransaction::class, + ], $metadata->discriminatorMap); + } + + /** + * @test + */ + public function it_does_not_re_add_classes_already_in_the_map(): void + { + $metadata = new ClassMetadata(LoyaltyTransaction::class); + $metadata->setDiscriminatorMap(['earn_order' => EarnOrderLoyaltyTransaction::class]); + + $listener = new DiscriminatorMapListener(LoyaltyTransaction::class, [ + 'setono_sylius_loyalty.transaction' => ['classes' => ['model' => EarnOrderLoyaltyTransaction::class]], + ]); + $listener->loadClassMetadata($this->eventArgs($metadata)); + + self::assertSame(['earn_order' => EarnOrderLoyaltyTransaction::class], $metadata->discriminatorMap); + } + + /** + * @test + */ + public function it_ignores_other_classes(): void + { + $metadata = new ClassMetadata(LoyaltyAccount::class); + + $listener = new DiscriminatorMapListener(LoyaltyTransaction::class, [ + 'app.badge_transaction' => ['classes' => ['model' => ManualCreditLoyaltyTransaction::class]], + ]); + $listener->loadClassMetadata($this->eventArgs($metadata)); + + self::assertSame([], $metadata->discriminatorMap); + } + + /** + * @param ClassMetadata $metadata + */ + private function eventArgs(ClassMetadata $metadata): LoadClassMetadataEventArgs + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + + return new LoadClassMetadataEventArgs($metadata, $entityManager->reveal()); + } +} diff --git a/tests/Unit/Expression/ExpressionSandboxTest.php b/tests/Unit/Expression/ExpressionSandboxTest.php new file mode 100644 index 0000000..3e7336c --- /dev/null +++ b/tests/Unit/Expression/ExpressionSandboxTest.php @@ -0,0 +1,186 @@ +validator()->validate('customer.group.code == "vip" and account.balance > 100'); + + $this->expectNotToPerformAssertions(); + } + + /** + * @test + */ + public function it_rejects_unknown_variables(): void + { + $this->expectException(InvalidExpressionException::class); + + $this->validator()->validate('container.get("doctrine")'); + } + + /** + * @test + */ + public function it_rejects_non_whitelisted_members(): void + { + $this->expectException(InvalidExpressionException::class); + $this->expectExceptionMessage('"password" is not available on "customer"'); + + $this->validator()->validate('customer.password'); + } + + /** + * @test + */ + public function it_rejects_method_calls(): void + { + $this->expectException(InvalidExpressionException::class); + $this->expectExceptionMessage('Method calls are not available'); + + $this->validator()->validate('customer.getEmail()'); + } + + /** + * @test + */ + public function it_rejects_unknown_functions(): void + { + $this->expectException(InvalidExpressionException::class); + + $this->validator()->validate('phpinfo()'); + } + + /** + * @test + */ + public function it_rejects_order_variables_for_action_triggers(): void + { + $this->expectException(InvalidExpressionException::class); + + $this->validator()->validate('basis > 100', 'customer_registered'); + } + + /** + * @test + */ + public function it_accepts_order_variables_for_the_order_trigger(): void + { + $this->validator()->validate('basis > 100', EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE); + + $this->expectNotToPerformAssertions(); + } + + /** + * @test + */ + public function it_rejects_syntax_errors(): void + { + $this->expectException(InvalidExpressionException::class); + + $this->validator()->validate('basis >'); + } + + /** + * @test + */ + public function it_evaluates_against_entities_through_the_getter_bridge(): void + { + $customer = $this->prophesize(CustomerInterface::class); + $customer->getEmail()->willReturn('jane@example.com'); + $customer->getFirstName()->willReturn('Jane'); + $customer->getLastName()->willReturn('Doe'); + $customer->getGroup()->willReturn(null); + + $channel = $this->prophesize(ChannelInterface::class); + $channel->getCode()->willReturn('WEB'); + $channel->getName()->willReturn('Web store'); + + $context = new EarningContext( + channel: $channel->reveal(), + customer: $customer->reveal(), + itemAmounts: [1 => 60000], + ); + + $result = $this->evaluator()->evaluate('basis > 50000 ? floor(basis / 50) : floor(basis / 100)', $context); + self::assertSame(1200.0, $result); + + self::assertSame('jane@example.com', $this->evaluator()->evaluate('customer.email', $context)); + self::assertSame('WEB', $this->evaluator()->evaluate('channel.code', $context)); + } + + /** + * @test + */ + public function it_overrides_the_basis_for_scoped_rules(): void + { + $channel = $this->prophesize(ChannelInterface::class); + $channel->getCode()->willReturn('WEB'); + $channel->getName()->willReturn('Web store'); + + $context = new EarningContext(channel: $channel->reveal(), itemAmounts: [1 => 60000]); + + self::assertSame(4000, $this->evaluator()->evaluate('basis', $context, 4000)); + } + + /** + * @test + */ + public function it_exposes_registered_functions(): void + { + $channel = $this->prophesize(ChannelInterface::class); + $channel->getCode()->willReturn('WEB'); + $channel->getName()->willReturn('Web store'); + + $context = new EarningContext( + channel: $channel->reveal(), + now: new \DateTimeImmutable('2026-07-06'), // a Monday + ); + + self::assertSame(1, $this->evaluator()->evaluate('day_of_week()', $context)); + } + + private function validator(): ExpressionValidator + { + return new ExpressionValidator(new ExpressionCatalog($this->functions()), $this->functions()); + } + + private function evaluator(): ExpressionEvaluator + { + return new ExpressionEvaluator($this->functions(), $this->validator()); + } + + private function functions(): ExpressionFunctionRegistry + { + $registry = new ExpressionFunctionRegistry(); + $registry->add(new DayOfWeekFunction()); + foreach (MathFunction::NAMES as $name) { + $registry->add(new MathFunction($name)); + } + + return $registry; + } +} diff --git a/tests/Unit/Ledger/LotReplayerTest.php b/tests/Unit/Ledger/LotReplayerTest.php new file mode 100644 index 0000000..6758acb --- /dev/null +++ b/tests/Unit/Ledger/LotReplayerTest.php @@ -0,0 +1,265 @@ +replay([ + self::credit(1, '2026-01-01', 100), + self::credit(2, '2026-01-02', 50), + ]); + + self::assertCount(2, $result->lots); + self::assertSame(100, $result->lots[0]->getRemaining()); + self::assertSame(50, $result->lots[1]->getRemaining()); + self::assertSame(150, $result->balance); + self::assertSame(0, $result->deficit); + self::assertSame([], $result->anomalies); + } + + /** + * @test + */ + public function it_consumes_the_earliest_expiring_lot_first(): void + { + // The older lot never expires; the newer lot expires soon and must be consumed first + $neverExpires = self::credit(1, '2026-01-01', 100); + $expiresSoon = self::credit(2, '2026-01-02', 100, '2026-06-01'); + + $result = (new LotReplayer())->replay([ + $neverExpires, + $expiresSoon, + self::debit(3, '2026-01-03', 60), + ]); + + self::assertSame(100, self::lotState($result, $neverExpires)->getRemaining()); + self::assertSame(40, self::lotState($result, $expiresSoon)->getRemaining()); + self::assertSame(140, $result->balance); + } + + /** + * @test + */ + public function it_spans_a_debit_across_multiple_lots(): void + { + $first = self::credit(1, '2026-01-01', 100, '2026-03-01'); + $second = self::credit(2, '2026-01-02', 100, '2026-04-01'); + $debit = self::debit(3, '2026-01-03', 150); + + $result = (new LotReplayer())->replay([$first, $second, $debit]); + + self::assertSame(0, self::lotState($result, $first)->getRemaining()); + self::assertSame(50, self::lotState($result, $second)->getRemaining()); + + $firstConsumptions = self::lotState($result, $first)->getConsumptions(); + self::assertCount(1, $firstConsumptions); + self::assertSame($debit, $firstConsumptions[0]->debit); + self::assertSame(100, $firstConsumptions[0]->points); + + $secondConsumptions = self::lotState($result, $second)->getConsumptions(); + self::assertCount(1, $secondConsumptions); + self::assertSame($debit, $secondConsumptions[0]->debit); + self::assertSame(50, $secondConsumptions[0]->points); + } + + /** + * @test + */ + public function it_zeroes_exactly_the_referenced_lot_on_expiration_and_records_the_remaining_before(): void + { + $lot = self::credit(1, '2026-01-01', 100, '2026-02-01'); + $other = self::credit(2, '2026-01-02', 100); + + $result = (new LotReplayer())->replay([ + $lot, + $other, + self::debit(3, '2026-01-03', 30), + self::expiration(4, '2026-02-02', 70, $lot), + ]); + + self::assertSame(0, self::lotState($result, $lot)->getRemaining()); + self::assertTrue(self::lotState($result, $lot)->isClosedByExpiration()); + self::assertSame(100, self::lotState($result, $other)->getRemaining()); + + self::assertCount(1, $result->expirations); + self::assertSame(70, $result->expirations[0]->remainingBefore); + self::assertSame(100, $result->balance); + } + + /** + * @test + */ + public function it_carries_a_deficit_into_subsequently_opened_lots(): void + { + $debit = self::debit(2, '2026-01-02', 80); + $lot = self::credit(3, '2026-01-03', 100); + + $result = (new LotReplayer())->replay([ + self::credit(1, '2026-01-01', 50, '2026-02-01'), + $debit, + $lot, + ]); + + // 50 consumed from the first lot, 30 carried into the next one + self::assertSame(70, self::lotState($result, $lot)->getRemaining()); + self::assertSame(0, $result->deficit); + self::assertSame(70, $result->balance); + + $consumptions = self::lotState($result, $lot)->getConsumptions(); + self::assertCount(1, $consumptions); + self::assertSame($debit, $consumptions[0]->debit); + self::assertSame(30, $consumptions[0]->points); + } + + /** + * @test + */ + public function it_reports_an_unserved_deficit(): void + { + $result = (new LotReplayer())->replay([ + self::credit(1, '2026-01-01', 50), + self::debit(2, '2026-01-02', 80), + ]); + + self::assertSame(30, $result->deficit); + self::assertSame(-30, $result->balance); + } + + /** + * @test + */ + public function it_does_not_consume_lots_already_expired_at_the_debits_occurrence(): void + { + // The lot expired on 2026-02-01 but the expiry cron has not closed it yet; a debit + // after that date must not consume it + $expired = self::credit(1, '2026-01-01', 100, '2026-02-01'); + + $result = (new LotReplayer())->replay([ + $expired, + self::debit(2, '2026-03-01', 60), + ]); + + self::assertSame(100, self::lotState($result, $expired)->getRemaining()); + self::assertSame(60, $result->deficit); + self::assertSame(40, $result->balance); + } + + /** + * @test + */ + public function it_reports_an_anomaly_for_an_expiration_referencing_an_unknown_lot(): void + { + $unknownLot = self::credit(99, '2026-01-01', 10); + + $result = (new LotReplayer())->replay([ + self::credit(1, '2026-01-01', 100), + self::expiration(2, '2026-01-02', 10, $unknownLot), + ]); + + self::assertCount(1, $result->anomalies); + self::assertStringContainsString('id: 2', $result->anomalies[0]); + } + + /** + * @test + */ + public function it_sorts_input_into_replay_order(): void + { + $lot = self::credit(1, '2026-01-01', 100, '2026-02-01'); + + // Deliberately shuffled input + $result = (new LotReplayer())->replay([ + self::debit(3, '2026-01-03', 30), + $lot, + self::debit(2, '2026-01-02', 20), + ]); + + self::assertSame(50, self::lotState($result, $lot)->getRemaining()); + self::assertSame(0, $result->deficit); + self::assertSame(50, $result->balance); + } + + /** + * @test + */ + public function it_uses_occurrence_then_id_as_tiebreaker_in_consumption_order(): void + { + // Same expiry: the earlier-occurred lot is consumed first; same occurrence: lower id first + $first = self::credit(2, '2026-01-01', 10, '2026-06-01'); + $second = self::credit(5, '2026-01-01', 10, '2026-06-01'); + $third = self::credit(7, '2026-01-02', 10, '2026-06-01'); + + $result = (new LotReplayer())->replay([ + $third, + $second, + $first, + self::debit(9, '2026-01-03', 15), + ]); + + self::assertSame(0, self::lotState($result, $first)->getRemaining()); + self::assertSame(5, self::lotState($result, $second)->getRemaining()); + self::assertSame(10, self::lotState($result, $third)->getRemaining()); + } + + private static function lotState(ReplayResult $result, EarnActionLoyaltyTransaction $lot): LotState + { + $lotState = $result->getLotState($lot); + self::assertNotNull($lotState); + + return $lotState; + } + + private static function credit(int $id, string $occurredAt, int $points, ?string $expiresAt = null): EarnActionLoyaltyTransaction + { + $credit = new EarnActionLoyaltyTransaction(); + $credit->setPoints($points); + $credit->setOccurredAt(new \DateTimeImmutable($occurredAt)); + $credit->setExpiresAt(null === $expiresAt ? null : new \DateTimeImmutable($expiresAt)); + self::setId($credit, $id); + + return $credit; + } + + private static function debit(int $id, string $occurredAt, int $points): RedeemLoyaltyTransaction + { + $debit = new RedeemLoyaltyTransaction(); + $debit->setPoints(-$points); + $debit->setOccurredAt(new \DateTimeImmutable($occurredAt)); + self::setId($debit, $id); + + return $debit; + } + + private static function expiration(int $id, string $occurredAt, int $points, EarnActionLoyaltyTransaction $lot): ExpireLoyaltyTransaction + { + $expiration = new ExpireLoyaltyTransaction(); + $expiration->setPoints(-$points); + $expiration->setOccurredAt(new \DateTimeImmutable($occurredAt)); + $expiration->setLot($lot); + self::setId($expiration, $id); + + return $expiration; + } + + private static function setId(LoyaltyTransaction $transaction, int $id): void + { + $reflection = new \ReflectionProperty(LoyaltyTransaction::class, 'id'); + $reflection->setValue($transaction, $id); + } +} diff --git a/tests/Unit/Ledger/LoyaltyLedgerTest.php b/tests/Unit/Ledger/LoyaltyLedgerTest.php new file mode 100644 index 0000000..ec41337 --- /dev/null +++ b/tests/Unit/Ledger/LoyaltyLedgerTest.php @@ -0,0 +1,297 @@ + */ + private ObjectProphecy $entityManager; + + /** @var ObjectProphecy */ + private ObjectProphecy $transactionRepository; + + /** @var ObjectProphecy */ + private ObjectProphecy $tierEvaluator; + + /** @var list */ + private array $persistedTransactions = []; + + protected function setUp(): void + { + $this->account = new LoyaltyAccount(); + $reflection = new \ReflectionProperty(LoyaltyAccount::class, 'id'); + $reflection->setValue($this->account, 1); + + $this->program = new LoyaltyProgram(); + $this->eventDispatcher = new EventDispatcher(); + $this->persistedTransactions = []; + + $repository = $this->prophesize(EntityRepository::class); + $repository->find(1, LockMode::PESSIMISTIC_WRITE)->willReturn($this->account); + + $this->entityManager = $this->prophesize(EntityManagerInterface::class); + $this->entityManager->getRepository(LoyaltyAccount::class)->willReturn($repository->reveal()); + $this->entityManager->wrapInTransaction(Argument::type('callable'))->will( + static function (array $args): mixed { + $callback = $args[0]; + \assert(is_callable($callback)); + + return $callback(); + }, + ); + $persisted = &$this->persistedTransactions; + $this->entityManager->persist(Argument::type(LoyaltyTransaction::class))->will( + static function (array $args) use (&$persisted): void { + $persisted[] = $args[0]; + }, + ); + // Credits flush before the tier evaluator so the bases see the new row + $this->entityManager->flush()->willReturn(null); + + $this->transactionRepository = $this->prophesize(LoyaltyTransactionRepositoryInterface::class); + $this->transactionRepository->findRedeemTransaction(Argument::any())->willReturn(null); + $this->transactionRepository->findForReplay(Argument::any())->willReturn([]); + $this->transactionRepository->findClawbackForEarn(Argument::any())->willReturn(null); + + $this->tierEvaluator = $this->prophesize(TierEvaluatorInterface::class); + } + + /** + * @test + */ + public function it_earns_points_for_an_order(): void + { + $pointsEarnedEvents = []; + $this->eventDispatcher->addListener(PointsEarned::class, static function (PointsEarned $event) use (&$pointsEarnedEvents): void { + $pointsEarnedEvents[] = $event; + }); + + $expiresAt = new \DateTimeImmutable('+365 days'); + $transaction = $this->ledger()->earnOrder($this->order(), 100, ['rule:1' => 100], 10000, $expiresAt); + + self::assertInstanceOf(EarnOrderLoyaltyTransaction::class, $transaction); + self::assertSame(100, $transaction->getPoints()); + self::assertSame($expiresAt, $transaction->getExpiresAt()); + self::assertSame(10000, $transaction->getBasisAmount()); + self::assertSame($this->account, $transaction->getAccount()); + self::assertSame(100, $this->account->getBalance()); + self::assertSame(100, $this->account->getLifetimeEarned()); + self::assertCount(1, $pointsEarnedEvents); + $this->tierEvaluator->evaluate($this->account)->shouldHaveBeenCalled(); + } + + /** + * @test + */ + public function it_skips_earning_for_a_disabled_account(): void + { + $this->account->setEnabled(false); + + self::assertNull($this->ledger()->earnOrder($this->order(), 100)); + self::assertSame(0, $this->account->getBalance()); + self::assertSame([], $this->persistedTransactions); + } + + /** + * @test + */ + public function it_lets_listeners_adjust_and_cancel_awards(): void + { + $this->eventDispatcher->addListener(AwardingPoints::class, static function (AwardingPoints $event): void { + $event->cancel(); + }); + + self::assertNull($this->ledger()->earnOrder($this->order(), 100)); + self::assertSame(0, $this->account->getBalance()); + self::assertSame([], $this->persistedTransactions); + } + + /** + * @test + */ + public function it_debits_redeemed_points(): void + { + $this->account->setBalance(500); + + $transaction = $this->ledger()->redeem($this->order(), 300); + + self::assertInstanceOf(RedeemLoyaltyTransaction::class, $transaction); + self::assertSame(-300, $transaction->getPoints()); + self::assertSame(200, $this->account->getBalance()); + // Redeeming never touches lifetime earned + self::assertSame(0, $this->account->getLifetimeEarned()); + } + + /** + * @test + */ + public function it_throws_when_the_balance_is_insufficient_for_redemption(): void + { + $this->account->setBalance(100); + + $this->expectException(InsufficientBalanceException::class); + + $this->ledger()->redeem($this->order(), 300); + } + + /** + * @test + */ + public function it_refuses_redemption_for_a_disabled_account(): void + { + $this->account->setBalance(500); + $this->account->setEnabled(false); + + $this->expectException(LedgerConflictException::class); + + $this->ledger()->redeem($this->order(), 300); + } + + /** + * @test + */ + public function it_claws_back_the_points_earned_for_an_order(): void + { + $this->account->setBalance(500); + $order = $this->order(); + + $earn = new EarnOrderLoyaltyTransaction(); + $earn->setAccount($this->account); + $earn->setPoints(100); + $this->transactionRepository->findEarnOrderTransaction($order)->willReturn($earn); + + $transaction = $this->ledger()->clawback($order, 100); + + self::assertNotNull($transaction); + self::assertSame(-100, $transaction->getPoints()); + self::assertSame($earn, $transaction->getEarn()); + self::assertSame(400, $this->account->getBalance()); + } + + /** + * @test + */ + public function it_clamps_a_clawback_to_zero_under_the_clamp_policy(): void + { + $this->account->setBalance(30); + $this->program->setClawbackPolicy(LoyaltyProgram::CLAWBACK_POLICY_CLAMP_TO_ZERO); + $order = $this->order(); + + $earn = new EarnOrderLoyaltyTransaction(); + $earn->setAccount($this->account); + $earn->setPoints(100); + $this->transactionRepository->findEarnOrderTransaction($order)->willReturn($earn); + + $transaction = $this->ledger()->clawback($order, 100); + + self::assertNotNull($transaction); + self::assertSame(-30, $transaction->getPoints()); + self::assertSame(0, $this->account->getBalance()); + } + + /** + * @test + */ + public function it_is_a_noop_when_the_order_earned_nothing(): void + { + $order = $this->order(); + $this->transactionRepository->findEarnOrderTransaction($order)->willReturn(null); + + self::assertNull($this->ledger()->clawback($order, 100)); + } + + /** + * @test + */ + public function it_allows_manual_adjustments_on_disabled_accounts(): void + { + $this->account->setBalance(100); + $this->account->setEnabled(false); + + $transaction = $this->ledger()->manualDebit($this->account, 40, 'correction', 'Support case #42'); + + self::assertInstanceOf(ManualDebitLoyaltyTransaction::class, $transaction); + self::assertSame(-40, $transaction->getPoints()); + self::assertSame('correction', $transaction->getReason()); + self::assertSame('Support case #42', $transaction->getNote()); + self::assertSame(60, $this->account->getBalance()); + } + + private function ledger(): LoyaltyLedger + { + $accountProvider = $this->prophesize(LoyaltyAccountProviderInterface::class); + $accountProvider->getByCustomerAndChannel(Argument::any(), Argument::any())->willReturn($this->account); + + $programProvider = $this->prophesize(LoyaltyProgramProviderInterface::class); + $programProvider->getByChannel(Argument::any())->willReturn($this->program); + + return new LoyaltyLedger( + $this->entityManager->reveal(), + $this->prophesize(ManagerRegistry::class)->reveal(), + $accountProvider->reveal(), + $this->transactionRepository->reveal(), + new LotReplayer(), + $programProvider->reveal(), + $this->tierEvaluator->reveal(), + $this->eventDispatcher, + new NullLogger(), + LoyaltyAccount::class, + ); + } + + private function order(): OrderInterface + { + $channel = $this->prophesize(ChannelInterface::class); + $customer = $this->prophesize(CustomerInterface::class); + + $order = $this->prophesize(OrderInterface::class); + $order->getCustomer()->willReturn($customer->reveal()); + $order->getChannel()->willReturn($channel->reveal()); + $order->getNumber()->willReturn('000000001'); + + $this->account->setChannel($channel->reveal()); + + return $order->reveal(); + } +} diff --git a/tests/Unit/Provider/LoyaltyAccountProviderTest.php b/tests/Unit/Provider/LoyaltyAccountProviderTest.php new file mode 100644 index 0000000..440b783 --- /dev/null +++ b/tests/Unit/Provider/LoyaltyAccountProviderTest.php @@ -0,0 +1,74 @@ +prophesize(CustomerInterface::class)->reveal(); + $channel = $this->prophesize(ChannelInterface::class)->reveal(); + $account = new LoyaltyAccount(); + + $repository = $this->prophesize(LoyaltyAccountRepositoryInterface::class); + $repository->findOneByCustomerAndChannel($customer, $channel)->willReturn($account); + + $factory = $this->prophesize(FactoryInterface::class); + $factory->createNew()->shouldNotBeCalled(); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + + $provider = new LoyaltyAccountProvider($repository->reveal(), $factory->reveal(), $managerRegistry->reveal()); + + self::assertSame($account, $provider->getByCustomerAndChannel($customer, $channel)); + } + + /** + * @test + */ + public function it_creates_an_account_lazily(): void + { + $customer = $this->prophesize(CustomerInterface::class)->reveal(); + $channel = $this->prophesize(ChannelInterface::class)->reveal(); + $account = new LoyaltyAccount(); + + $repository = $this->prophesize(LoyaltyAccountRepositoryInterface::class); + $repository->findOneByCustomerAndChannel($customer, $channel)->willReturn(null); + + $factory = $this->prophesize(FactoryInterface::class); + $factory->createNew()->willReturn($account); + + $manager = $this->prophesize(ObjectManager::class); + $manager->persist($account)->shouldBeCalled(); + $manager->flush()->shouldBeCalled(); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass(LoyaltyAccount::class)->willReturn($manager->reveal()); + + $provider = new LoyaltyAccountProvider($repository->reveal(), $factory->reveal(), $managerRegistry->reveal()); + + $result = $provider->getByCustomerAndChannel($customer, $channel); + + self::assertSame($account, $result); + self::assertSame($customer, $result->getCustomer()); + self::assertSame($channel, $result->getChannel()); + } +} diff --git a/tests/Unit/Tier/TierEvaluatorTest.php b/tests/Unit/Tier/TierEvaluatorTest.php new file mode 100644 index 0000000..3429ffd --- /dev/null +++ b/tests/Unit/Tier/TierEvaluatorTest.php @@ -0,0 +1,254 @@ +account(); + $silver = $this->tier('silver', position: 1, threshold: 1000); + $gold = $this->tier('gold', position: 2, threshold: 5000); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 1200, dispatched: $dispatched); + + $evaluator->evaluate($account); + + self::assertSame($silver, $account->getTier()); + self::assertNull($account->getTierBelowThresholdSince()); + self::assertInstanceOf(TierChanging::class, $dispatched[0]); + self::assertInstanceOf(TierChanged::class, $dispatched[1]); + self::assertSame($silver, $dispatched[1]->to); + } + + /** + * @test + */ + public function it_picks_the_highest_qualifying_tier(): void + { + $account = $this->account(); + $silver = $this->tier('silver', position: 1, threshold: 1000); + $gold = $this->tier('gold', position: 2, threshold: 5000); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 9000, dispatched: $dispatched); + + $evaluator->evaluate($account); + + self::assertSame($gold, $account->getTier()); + } + + /** + * @test + */ + public function it_never_downgrades_inline(): void + { + $gold = $this->tier('gold', position: 2, threshold: 5000); + $silver = $this->tier('silver', position: 1, threshold: 1000); + + $account = $this->account(); + $account->setTier($gold); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 1200, dispatched: $dispatched); + + $evaluator->evaluate($account); + + self::assertSame($gold, $account->getTier()); + self::assertSame([], $dispatched); + } + + /** + * @test + */ + public function it_keeps_the_current_tier_when_the_change_is_cancelled(): void + { + $account = $this->account(); + $silver = $this->tier('silver', position: 1, threshold: 1000); + + $dispatched = []; + $evaluator = $this->evaluator([$silver], metric: 1200, dispatched: $dispatched, cancel: true); + + $evaluator->evaluate($account); + + self::assertNull($account->getTier()); + } + + /** + * @test + */ + public function it_downgrades_immediately_at_reconciliation_without_grace(): void + { + $gold = $this->tier('gold', position: 2, threshold: 5000); + $silver = $this->tier('silver', position: 1, threshold: 1000); + + $account = $this->account(); + $account->setTier($gold); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 1200, dispatched: $dispatched, graceDays: 0); + + $evaluator->reconcile($account, new \DateTimeImmutable('2026-07-06 03:00:00')); + + self::assertSame($silver, $account->getTier()); + self::assertNull($account->getTierBelowThresholdSince()); + } + + /** + * @test + */ + public function it_keeps_the_tier_within_the_grace_period(): void + { + $gold = $this->tier('gold', position: 2, threshold: 5000); + $silver = $this->tier('silver', position: 1, threshold: 1000); + + $account = $this->account(); + $account->setTier($gold); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 1200, dispatched: $dispatched, graceDays: 30); + + $firstRun = new \DateTimeImmutable('2026-07-06 03:00:00'); + $evaluator->reconcile($account, $firstRun); + + self::assertSame($gold, $account->getTier()); + self::assertSame($firstRun, $account->getTierBelowThresholdSince()); + + // Still within grace 29 days later + $evaluator->reconcile($account, new \DateTimeImmutable('2026-08-04 03:00:00')); + self::assertSame($gold, $account->getTier()); + } + + /** + * @test + */ + public function it_downgrades_after_the_grace_period(): void + { + $gold = $this->tier('gold', position: 2, threshold: 5000); + $silver = $this->tier('silver', position: 1, threshold: 1000); + + $account = $this->account(); + $account->setTier($gold); + $account->setTierBelowThresholdSince(new \DateTimeImmutable('2026-06-01 03:00:00')); + + $dispatched = []; + $evaluator = $this->evaluator([$gold, $silver], metric: 1200, dispatched: $dispatched, graceDays: 30); + + $evaluator->reconcile($account, new \DateTimeImmutable('2026-07-06 03:00:00')); + + self::assertSame($silver, $account->getTier()); + self::assertNull($account->getTierBelowThresholdSince()); + } + + /** + * @test + */ + public function it_clears_the_grace_clock_when_requalified(): void + { + $gold = $this->tier('gold', position: 2, threshold: 5000); + + $account = $this->account(); + $account->setTier($gold); + $account->setTierBelowThresholdSince(new \DateTimeImmutable('2026-06-20 03:00:00')); + + $dispatched = []; + $evaluator = $this->evaluator([$gold], metric: 6000, dispatched: $dispatched, graceDays: 30); + + $evaluator->reconcile($account, new \DateTimeImmutable('2026-07-06 03:00:00')); + + self::assertSame($gold, $account->getTier()); + self::assertNull($account->getTierBelowThresholdSince()); + } + + private function account(): LoyaltyAccount + { + $account = new LoyaltyAccount(); + $account->setChannel($this->prophesize(ChannelInterface::class)->reveal()); + + return $account; + } + + private function tier(string $code, int $position, int $threshold): Tier + { + $tier = new Tier(); + $tier->setCode($code); + $tier->setPosition($position); + $tier->setThreshold($threshold); + $tier->setQualificationBasis(self::POINTS_EARNED); + + return $tier; + } + + /** + * @param list $tiers ordered by position, highest first + * @param list $dispatched collects dispatched events + */ + private function evaluator(array $tiers, int $metric, array &$dispatched, int $graceDays = 0, bool $cancel = false): TierEvaluator + { + $tierRepository = $this->prophesize(TierRepositoryInterface::class); + $tierRepository->findQualifiable(Argument::any())->willReturn($tiers); + + $basis = $this->prophesize(TierQualificationBasisInterface::class); + $basis->getCode()->willReturn(self::POINTS_EARNED); + $basis->calculate(Argument::cetera())->willReturn($metric); + + $registry = $this->prophesize(TierQualificationBasisRegistryInterface::class); + $registry->get(self::POINTS_EARNED)->willReturn($basis->reveal()); + + $program = new LoyaltyProgram(); + $program->setTierDowngradeGraceDays($graceDays); + + $programProvider = $this->prophesize(LoyaltyProgramProviderInterface::class); + $programProvider->getByChannel(Argument::any())->willReturn($program); + + $windowResolver = $this->prophesize(EvaluationWindowResolverInterface::class); + $windowResolver->resolve($program)->willReturn(null); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::type('object'))->will(function (array $args) use (&$dispatched, $cancel): object { + $event = $args[0]; + \assert(is_object($event)); + if ($cancel && $event instanceof TierChanging) { + $event->cancel(); + } + $dispatched[] = $event; + + return $event; + }); + + return new TierEvaluator( + $tierRepository->reveal(), + $registry->reveal(), + $programProvider->reveal(), + $windowResolver->reveal(), + $eventDispatcher->reveal(), + ); + } +}