` to gain native keyboard activation. |
+| WR-04 | Warning | Retry endpoint dispatches the job even when the form's MailerLite sync is currently disabled — bypasses kill-switch contract. | RetryController honors current `$config['enabled']` flag and returns 422 if disabled. Also `throw $e` added after `$this->fail($e)` so direct callers see failure. |
+| WR-05 | Warning | Activity log query has no LIMIT — single-form scan returns all rows since beginning of time. | `->limit(500)` cap on the ActivityController query to bound Inertia payload size. |
+| WR-06 | Warning | Inconsistent `groups` validation between primary-sync and post-mapped retry paths — primary forwards malformed ints to SDK; retry silently drops them. | Both paths now use `array_values(array_filter($groupsRaw, 'is_string'))`. |
+| WR-07 | Warning | `$row->submission_id ?? 'retry-'.$row->id` does not handle empty-string original IDs (`??` only triggers on null). | Replaced with `! empty($row->submission_id) ? ... : 'retry-'.$row->id`. |
+
+---
+
+## Phase 5 — Multi-Tenant Storage
+
+Source: `.planning/phases/05-multi-tenant-storage/05-CONTEXT.md`
+
+### Decisions (D-XX, Phase 5)
+
+| ID | Title | Decision | Reference |
+|---|---|---|---|
+| D-01 | SUPERSEDED — Drivers dropped | Originally: ship both `SpatieDatabaseDriver` + `SpatieFileDriver` per ROADMAP TEN-01. SUPERSEDED by [[D-15]] + [[D-16]] — Phase 5 ships **neither** Spatie driver; all three enum cases resolve to `AddonYamlDriver`. | 05-CONTEXT.md §Driver scope |
+| D-02 | Discrimination heuristic research-deferred | Researcher must verify Spatie v4's canonical "is multitenancy active" idiom (candidate: `config('multitenancy.tenant_database_connection_name')`). Resolved with [[D-13]]. | 05-CONTEXT.md §Driver scope |
+| D-03 | SUPERSEDED — DB driver schema | Originally per-tenant `mailerlite_settings` table with `UsesTenantConnection`. SUPERSEDED by [[D-15]] — addon delegates to host's Statamic settings stack. | 05-CONTEXT.md §Storage location (SUPERSEDED) |
+| D-04 | SUPERSEDED — First-write semantics | Originally `updateOrCreate`/`firstOrCreate` on every write. Moot per [[D-15]]. | 05-CONTEXT.md §Storage location (SUPERSEDED) |
+| D-05 | Activity log per-tenant DB (TENTATIVE) | In Spatie DB mode, `mailerlite_activity_log` moves to per-tenant DB. `ActivityLogEntry` uses `UsesTenantConnection` trait dynamically (only when Spatie active). Migration split: `landlord/` + `tenant/`. Researcher confirmed (Option A) mode-aware routing. | 05-CONTEXT.md §Activity log connection |
+| D-06 | ensureCurrentTenant() guard | Phase 4 controllers (`Dashboard`, `Activity`, `Retry`) gain defensive `ensureCurrentTenant()` guard at method entry — `if (! Tenant::current()) { abort(403); }`. Belt-and-braces; short-circuits in non-Spatie mode. | 05-CONTEXT.md §Activity log connection |
+| D-07 | SUPERSEDED — File driver layout | Originally `SpatieFileDriver` per-tenant files at `storage/app/tenants/{id}/mailerlite/`. SUPERSEDED by [[D-16]]. | 05-CONTEXT.md §Storage layout (SUPERSEDED) |
+| D-08 | SUPERSEDED — Symfony Yaml parsing | Moot per [[D-16]] — addon writes via Statamic's native settings facade, not direct file I/O. | 05-CONTEXT.md §Storage layout (SUPERSEDED) |
+| D-09 | Job implements TenantAware | `SyncSubscriberJob` adds `implements TenantAware` (FQCN `Spatie\Multitenancy\Jobs\TenantAware`) alongside existing `ShouldQueue`. Additive change per Phase 2 [[D-06]]. | 05-CONTEXT.md §Job + tenant lifecycle |
+| D-10 | Listener tenant-ID resolution | `SubmissionCreatedListener` resolves `tenantId` via `Tenant::current()?->id` when Spatie active, `null` otherwise. Single line change. | 05-CONTEXT.md §Job + tenant lifecycle |
+| D-11 | spatie/laravel-multitenancy in require-dev | Add `spatie/laravel-multitenancy: ^4.0` to addon's `require-dev`. Tests boot real Spatie in Testbench with two tenant rows. Also drives the polyfill at `src/Compat/spatie-tenant-aware-polyfill.php` — optional-dep guarantee for hosts without Spatie installed. | 05-CONTEXT.md §Test harness |
+| D-12 | Test base separation | Keep current `AddonTestCase` for Phase 1-4 / Mode A regressions; add `MultitenantTestCase` for Phase 5 tenant-aware tests. Prevents accidental Mode A regression under Spatie-boot context. | 05-CONTEXT.md §Test harness |
+| D-13 | Detector heuristic refinement | With Spatie installed as dev-dep, `class_exists(Multitenancy::class)` returns true in EVERY test. Researcher verified canonical Spatie v4 "is multitenancy active" check; detector uses verified idiom against v4.1.1 vendor source. | 05-CONTEXT.md §Test harness |
+| D-14 | No migration command v1.0 | No `mailerlite:migrate-to-multitenancy` command. README documents upgrade path: "set the API key + form configs per-tenant via the CP." | 05-CONTEXT.md §Onboarding |
+| D-15 | PIVOT — Delegate DB mode to host | In Spatie DB mode, addon delegates per-tenant API key + form configs to host's Statamic settings stack via `AddonYamlDriver` (which writes via `Statamic\Facades\Addon::get(...)->settings()`). Host MUST install `statamic/eloquent-driver` + tenant-aware bindings (e.g. `concept7/statamic-multitenancy-eloquent`). | 05-CONTEXT.md §DB-mode delegation |
+| D-16 | SpatieFileDriver dropped | `SpatieFileDriver` also dropped. All three enum cases resolve to `AddonYamlDriver`. Pure file-mode multi-tenant Spatie hosts NOT supported in v1.0 — documented limitation. | 05-CONTEXT.md §File-mode dropped |
+
+### Requirements (TEN-XX)
+
+| ID | Description |
+|---|---|
+| TEN-01 | When `spatie/laravel-multitenancy` is detected at boot, the addon resolves the matching storage driver automatically — host installs no addon-side switch. |
+| TEN-02 | API key, per-form configs, and activity log entries are tenant-scoped — Tenant A's data is never readable from Tenant B's request or job context. |
+| TEN-03 | `SyncSubscriberJob` re-applies tenant context inside `handle()` via Spatie's `TenantAware` middleware so storage reads resolve under the correct tenant. |
+| TEN-04 | Addon installs and runs on plain Statamic with no `spatie/laravel-multitenancy` present — graceful fallback to `AddonYamlDriver`, no missing-class errors. |
+
+---
+
+## Phase 5.1 — CP Styling Refresh
+
+Source: `.planning/phases/05.1-cp-styling-concept7-branding/05.1-CONTEXT.md`, `05.1-SPEC.md`
+
+### Decisions (D-XX, Phase 5.1)
+
+| ID | Title | Decision | Reference |
+|---|---|---|---|
+| D-01 | Statamic-shipped palette only | No custom CSS file, no addon Tailwind JIT, no CSS custom properties. Statamic CP's compiled CSS does not contain `indigo`/`violet`/`emerald`/`slate`/`zinc`. SPEC.md "indigo" choice superseded. | 05.1-CONTEXT.md §Accent delivery |
+| D-02 | Accent palette = `purple` | Statamic CP ships `purple` with `light #e0b7ff`, DEFAULT `#c471ed`, dark `#a855cd`. Lavender-violet — closest "indigo-flavored" option. Vivid, distinct from Statamic's default `blue` (#43a9ff) and from semantic green/amber/red status badges. | 05.1-CONTEXT.md §Accent delivery |
+| D-03 | Scope = `resources/js/cp/**/*.{vue,js}` | Faithful 1:1 swap across Vue + JS files. **Includes `cp/index.js`** (D-15 fragment-focus pulse). No new accent splashes, no accent removed. SPEC R1 acceptance grep widens to `*.{vue,js}`. | 05.1-CONTEXT.md §Accent delivery |
+| D-04 | Token mapping table | `text-blue-600 → text-purple`, `dark:text-blue-400 → dark:text-purple-light`, `bg-blue-600 → bg-purple`, `hover:bg-blue-700 → hover:bg-purple-dark`, `border-blue-600 → border-purple`, `ring-blue-500 → ring-purple` (light) / `dark:ring-purple-light` (dark), `focus:ring-blue-500/{20,30,40} → focus:ring-purple/{20,30,40}`, `focus:border-blue-500 → focus:border-purple`, `focus:outline-blue-500 → focus:outline-purple`. D-15 pulse classes update; 1.5s duration unchanged. | 05.1-CONTEXT.md §Accent delivery |
+| D-05 | Two ghost tokens, not one | SPEC.md flagged only `gray-850`; scout reveals `gray-50` also missing. Statamic's gray scale: `100, 200, 300, 400, 500, 600, 700, 750, 775, 800, 900, 950`. | 05.1-CONTEXT.md §Token cleanup |
+| D-06 | `gray-50 → gray-100` | Used at 6 sites (table headers, table row hover, Settings Test connection button gradient stop, Activity payload modal ``). Closest semantic match. | 05.1-CONTEXT.md §Token cleanup |
+| D-07 | `gray-850 → gray-800` + `gray-900` pair on Test button | Settings Test connection button uses `bg-gradient-to-b dark:from-gray-850 dark:hover:to-gray-850`. Cleanup: `dark:from-gray-800` for base + `dark:hover:to-gray-900` for hover — preserves "darker-on-hover" direction. | 05.1-CONTEXT.md §Token cleanup |
+| D-08 | Light-mode hover delta correction | When `to-gray-50 → to-gray-100`, existing `hover:to-gray-100` collapses (no visible delta). Adjust hover `to-gray-100 → hover:to-gray-200`. Preserves visible darken-on-hover symmetrically with [[D-07]]. | 05.1-CONTEXT.md §Token cleanup |
+| D-09 | Inter already loaded by Statamic | `vendor/statamic/cms/resources/css/core/fonts.css` self-hosts Inter Variable; Tailwind `font-sans` resolves to `['Inter var', '-apple-system', ...]`. **SPEC R2 satisfied without explicit work.** No Google Fonts import, no `@font-face`, no `font-sans` class additions. | 05.1-CONTEXT.md §Inter font |
+| D-10 | Full planner latitude within SPEC | Planner fixes additional inconsistencies (mixed border weights, irregular padding, inconsistent shadow tokens) discovered during implementation, within SPEC R4–R7. Per-file atomic commits. | 05.1-CONTEXT.md §Planner latitude |
+
+### SPEC Requirements (SPEC-RX)
+
+Source: `.planning/phases/05.1-cp-styling-concept7-branding/05.1-SPEC.md`
+
+| ID | Requirement | Acceptance |
+|---|---|---|
+| SPEC-R1 | Accent color swap: all `*-blue-*` Tailwind tokens in the three Vue files replaced with `*-purple-*` equivalents (CONTEXT [[D-02]] supersedes the SPEC's original "indigo"). | `grep -rE 'text-blue-|bg-blue-|ring-blue-|border-blue-|from-blue-|to-blue-|outline-blue-|focus:.*-blue-' addons/concept7/statamic-mailerlite/resources/js/cp/` returns 0 lines (scope widened to `*.{vue,js}` per [[D-03]]). |
+| SPEC-R2 | Inter font applied: the three CP pages render with Inter as their primary font. | DevTools Computed panel for body shows `font-family` resolving to `Inter` on each page. **Satisfied without work per [[D-09]] — Statamic CP self-hosts Inter Variable.** |
+| SPEC-R3 | Color token cleanup: all ad-hoc shades outside Statamic's gray scale replaced with on-scale tokens. | `grep -rE '(from|to|via|bg|text|border|ring|outline)-gray-850' returns 0 lines; same grep against any other off-scale shade returns 0. Includes `gray-50` per [[D-05]]. |
+| SPEC-R4 | Typography hierarchy unified: heading and body sizes/weights/tracking consistent across the three pages. | Reviewer samples two H1-equivalent headings and two body paragraphs per page (6 H1 + 6 body samples across 3 pages); each role's three samples render identical font-size + weight + letter-spacing in DevTools Computed view. |
+| SPEC-R5 | Borders, radius, and shadow refined: container borders soften, radii bump to `≥ rounded-lg`, card shadows layer. | Every top-level card/table wrapper across the three pages uses one chosen radius token (≥ `rounded-lg`) and one chosen shadow token; no card uses `rounded-md` + bare `shadow-sm` after the change. |
+| SPEC-R6 | Spacing scale normalized: padding inside cards, table cells, page headers uses uniform scale. | Reviewer samples outermost page container, first top-level card, and first table cell on each of the three pages; the three corresponding samples across pages use the same Tailwind padding utility classes. |
+| SPEC-R7 | Dark mode parity: every visual change preserves both light and dark mode at equal polish. | Every screenshot in the 26-screenshot verification set captured in BOTH light and dark mode; reviewer signs off both modes per surface. |
+| SPEC-R8 | Build pipeline produces fresh manifest: `npm run build` from addon dir rebuilds Vite bundle AND republishes into host's `public/vendor/statamic-mailerlite/build/`. | After build, `diff <(jq . addons/concept7/statamic-mailerlite/public/build/manifest.json) <(jq . public/vendor/statamic-mailerlite/build/manifest.json)` returns 0; host CP loads new bundle (no stale-bundle white-screen / Inertia component-not-found error). |
+
+---
+
+## Phase 6 — Test Suite + Distribution
+
+Source: `.planning/REQUIREMENTS.md` §Distribution & Quality, `.planning/ROADMAP.md` §Phase 6
+
+### Decisions (D-XX, Phase 6)
+
+Pre-distribution refactor decisions captured during the Phase 6 readiness pass (Commits A "drop multi-tenancy", B "use Statamic's native addon-settings blueprint", D "file-backed activity log", and E "submissions-as-source-of-truth activity log" against the marketplace target repo `f/js/gsd-mailerlite`).
+
+| ID | Title | Decision | Reference |
+|---|---|---|---|
+| D-01 | Drop multi-tenancy from v1.0 | Phase 5's multi-tenant pivot (TEN-01..04, all Phase 5 D-XX, `TenancyDetector`, `StorageMode` enum, `EnsuresCurrentTenant` trait, polyfill, `MultitenantTestCase`, tenant migrations directory) is removed from the marketplace build. Single-mode addon shipping `AddonYamlDriver` only. Rationale: scope tightening for a v1.0 marketplace release; Spatie multi-tenant deployments are a v1.x feature. | Commit A on `concept7/statamic-mailerlite#3` (`c3ee0ed refactor: drop multi-tenancy support`) |
+| D-02 | Statamic native addon-settings blueprint | Custom Inertia/Vue settings page (Phase 3 [[D-01]]) replaced by `resources/blueprints/settings.yaml` — Statamic auto-discovers this and renders the API-key field under Tools → Addons → MailerLite. Removed: `SettingsController.php`, `EditPage.vue`, three settings routes, `mailerlite-test-connection` rate limiter + registration, the `Crypt::encryptString`/`decryptString` boundary in `AddonYamlDriver`. Field uses `input_type: password` for visual masking only — values persist plaintext (see [[T-PLAINTEXT-API-KEY]]). Per colleague review: a Statamic addon should use the framework's standard pattern. | Commit B on `concept7/statamic-mailerlite#3` (per Commit B's message); `https://statamic.dev/addons/building-an-addon#settings` |
+| D-03 | Plaintext API key at rest | Driver's `saveApiKey()` / `getApiKey()` write/read raw values; no encryption layer. Consequence of [[D-02]] — Statamic's native settings UI does not encrypt, and round-trip consistency forces the driver to match. Supersedes Phase 1 [[D-05]], [[D-06]], and [[STOR-04]]. Mitigation is operational, not code-level: README documents the requirement to lock down `resources/addons/` file permissions. | Commit B message; [[T-PLAINTEXT-API-KEY]] |
+| D-04 | SUPERSEDED — File-backed activity log (JSONL) | Originally: `mailerlite_activity_log` DB table replaced by JSON Lines at `storage/app/concept7/mailerlite/activity-log.jsonl`; `ActivityLogStore` owned file I/O (append with `LOCK_EX`, rewrite-for-prune); `PruneActivityLogCommand` scheduled daily for retention. SUPERSEDED by [[D-05]] — per colleague review: a Statamic addon should derive activity state from existing Submission records, not maintain a parallel store. The intermediate JSONL state never reached customers (landed and superseded within the same pre-distribution pass). | Commit D superseded by Commit E on `concept7/statamic-mailerlite#3` |
+| D-05 | Submissions-as-source-of-truth activity log | Activity log persisted as a `mailerlite_sync.attempts[]` block on each Statamic form Submission YAML (Multi-attempt — Option B). Schema per attempt: `{id, attempted_at, status, payload, error_message}`. Removed: `ActivityLogStore`, `PruneActivityLogCommand`, the provider's `schedule()` method, the `storage/app/concept7/mailerlite/activity-log.jsonl` file path. `ActivityLogEntry` becomes a read-side projection DTO with `forSubmission()`, `forForm()`, `all()`, and `find($id, $formHandle = null)` factories that iterate Statamic form/submission API. `ActivityLogger::log()` writes via `Form::find($formHandle)->submission($submissionId)->set('mailerlite_sync', ...)->save()`. Five consumers refactored: `ActivityLogger`, `FormHealthSummary` (groupBy now flattens cross-form submission iteration), `ActivityController` (uses `forForm()` for efficiency), `RetryController` (uses `find($id, $formHandle)` scoped lookup — also hardens [[T-04-IDOR]] structurally), `SyncSubscriberJob` finally block (call site unchanged; logger absorbs the storage swap). **Design constraint:** forms with `store: false` get no activity log entries (no on-disk submission to annotate); logger warns and skips. Supersedes Phase 6 [[D-04]] and Phase 2 [[D-16]], [[D-17]] (amended again — `tenant_id` dropped at D-01, then in-attempt schema at D-05), [[D-21]]. | Commit E on `concept7/statamic-mailerlite#3`; colleague's review (2026-05-20: "use submissions"); [[T-LOG-WRITE-FAILURE]] |
+
+### Requirements (DIST-XX)
+
+| ID | Description |
+|---|---|
+| DIST-01 | Pest 4 test suite covers all three storage drivers, `MailerLiteService`, `SyncSubscriberJob`, listeners, fieldtypes, CP routes, and a tenant-isolation integration test asserting Tenant A's key is never used in Tenant B's job. |
+| DIST-02 | Larastan / PHPStan passes clean at level 5. |
+| DIST-03 | Pint-formatted PSR-12 across `src/` and `tests/`. |
+| DIST-04 | README replaces the Spatie skeleton placeholder with installation, configuration, multi-tenant setup, and screenshots. |
+| DIST-05 | `composer.json` `extra.statamic` block populated for Marketplace listing (name, description, marketing copy). |
+| DIST-06 | Marketplace license check stub exists at a known integration point — no enforcement yet, but the seam is in place to wire actual validation in v1.x. |
+| DIST-07 | `composer.json` set to `minimum-stability: stable` (currently `dev` in skeleton) before any Marketplace submission. |
+
+---
+
+## Threats (T-XX) — cross-phase
+
+Source: phase CONTEXT.md `` blocks + phase PLAN.md threat tables
+
+| ID | Threat | Mitigation | Phase | File(s) where mitigation lives |
+|---|---|---|---|---|
+| T-04-IDOR | A retry call references an activity log entry whose `form_handle` belongs to a different form (URL parameter tampering). | `RetryController` asserts `$entry->form_handle === $formHandle` before dispatch; mismatched → 404. | Phase 4 | `src/CP/RetryController.php` |
+| T-04-NAV-LEAK | Top-level "MailerLite" nav entry visible to users without the `configure mailerlite` permission — leaks addon presence + invites permission-check tampering. | Nav entry gated by `Nav::extend(...)->can('configure mailerlite')` so it doesn't render for unauthorized users. | Phase 4 | `src/StatamicMailerLiteServiceProvider.php` (registerCpNav) |
+| T-04-RETRY-STAMPEDE | Rage-clicking Retry during MailerLite outages floods the queue with redundant jobs and burns MailerLite quota. | `RateLimiter::for('mailerlite-retry', ...)` 5/min/user ([[D-11]] Phase 4) + route `throttle:mailerlite-retry` middleware. Returns 429 with toast copy. | Phase 4 | `src/StatamicMailerLiteServiceProvider.php`, `src/CP/RetryController.php` |
+| T-API-KEY-IN-INERTIA-PROP | OBSOLETED by Phase 6 [[D-02]] — no Inertia settings page ships in v1.0; Statamic's native settings UI is the entry point. Original mitigation: Vue page's input empty on load; controller never sent `apiKey` as Inertia prop. | n/a | Phase 3 | (removed in Commit B — `SettingsController.php` + `EditPage.vue` deleted) |
+| T-API-KEY-LEAK-IN-VALIDATION-ERROR | OBSOLETED by Phase 6 [[D-02]] — custom Laravel validation rule deleted with `SettingsController`. Statamic's native settings form owns validation; messages do not echo submitted values. | n/a | Phase 3 | (removed in Commit B) |
+| T-DECRYPT-FAIL-LEAK | OBSOLETED by Phase 6 [[D-02]] — no decryption step; `getApiKey()` returns raw stored string. Rotated-`APP_KEY` / manual-edit failure modes no longer exist. | n/a | Phase 1 | (no Crypt call in `AddonYamlDriver.php` after Commit B) |
+| T-FT-CACHE-CROSS-TENANT | Per-request cache in fieldtypes (Phase 3 [[D-15]]) keyed only by endpoint would leak prior tenant's fields/groups into second tenant's CP on a tenant-switch mid-request. | Cache key includes `sha256(api_key)` so different keys produce different cache buckets. Phase 5 multi-tenant work hardens further. | Phase 3 | `src/Fieldtypes/MailerLiteFieldFieldtype.php`, `src/Fieldtypes/MailerLiteGroupFieldtype.php` |
+| T-MASS-ASSIGNMENT | `FormSavedListener` persisting full form data collection allows operator-controlled keys to bleed into addon storage → injection / unexpected storage schema. | Listener extracts only the MailerLite-section subset (`enabled`, `fields`, `groups`); persisting the full form data collection is prohibited. Phase 3 [[D-19]] + [[D-20]]. | Phase 3 | `src/Listeners/FormSavedListener.php` |
+| T-TEST-CONNECTION-LOCKOUT | OBSOLETED by Phase 6 [[D-02]] — test-connection endpoint removed; no quota-burn surface remains. The Phase 4 retry limiter (`mailerlite-retry`, [[T-04-RETRY-STAMPEDE]]) is a separate mitigation and stays. | n/a | Phase 3 | (limiter + registration removed in Commit B) |
+| T-PLAINTEXT-API-KEY | API key stored plaintext in `resources/addons/{slug}.yaml` after the Phase 6 [[D-02]] encryption removal — any process / user with read access to that file can exfiltrate the key. | **Operational, not code-level:** README documents the requirement to lock down `resources/addons/` file permissions (`chmod 600` + correct owner). No runtime mitigation — it's an explicit v1.0 tradeoff to use Statamic's native settings stack. | Phase 6 (accepted) | `README.md` (security notes), `resources/blueprints/settings.yaml` (uses `input_type: password` for visual masking only) |
+| T-FIELDTYPE-EXCEPTION-LEAK | A network blip during CP page render would render form-edit screen with 500 error if fieldtype SDK calls weren't caught. | Both fieldtypes wrap SDK call in `try { ... } catch (MailerLiteValidationException|MailerLiteHttpException $e) { Log::warning(...); return []; }`. Phase 3 [[D-17]] / [[FT-04]]. | Phase 3 | `src/Fieldtypes/MailerLite*Fieldtype.php` |
+| T-PERMISSION-BYPASS-VIA-DIRECT-ROUTE | If route registration forgets the `configure mailerlite` permission middleware, regular CP user could hit `/cp/mailerlite/settings` and read the API key. | Middleware on route group enforces; integration test asserts 403 for non-permitted user. Phase 3 [[D-12]]. | Phase 3 | `routes/cp.php`, `src/StatamicMailerLiteServiceProvider.php` |
+| T-FIELDTYPE-EAGER-FETCH-ON-LIST-PAGE | If `getIndexItems()` runs on every form-list page render, each list-page load makes a MailerLite API call → quota burn. | Researcher verified Relationship fieldtypes are NOT eager-fetched in list views — only on form-edit screen. No further mitigation needed. | Phase 3 | (research verdict — see 03-RESEARCH.md) |
+| T-TENANT-LEAK-VIA-SINGLETON | `singleton()` binding of `StorageDriverInterface` would survive across requests/jobs → Tenant A's driver instance reads Tenant B's data. | `scoped()` binding ([[STOR-03]]) — `forgetScopedInstances()` produces fresh instance between requests/jobs. ContainerBindingTest pins this. | Phase 1 | `src/StatamicMailerLiteServiceProvider.php` (register) |
+| T-FACADE-CACHE-LEAK | A future Facade over the storage driver would cache the resolved instance across jobs (laravel/framework#43151) and leak tenant context. | Structural absence: no Facade exists for `StorageDriverInterface`. Driver resolved via `app(...)` or constructor injection only. Test asserts no `abstractAliases` entry. | Phase 1 | `src/Storage/` (no Facades subdir) |
+| T-WRITE-FAILURE-SILENT | `saveApiKey` / `saveFormConfig` silently swallowing persistence errors → operator sees nothing while writes vanish. | [[D-13]] loud-write: `catch (Throwable $e)` re-throws as `MailerLiteStorageException` with `previous: $e`. | Phase 1 | `src/Storage/AddonYamlDriver.php`, `src/Storage/Exceptions/MailerLiteStorageException.php` |
+| T-SHAPE-VALIDATION-BYPASS | `saveFormConfig` accepting malformed `$data` → bad data persisted, breaks downstream consumers. | Accepted at storage layer per [[D-14]] — Phase 3 repository/fieldtype layer owns runtime validation. Phase 2 [[CR-02]] adds defensive check in `mapFields()`. | Phase 1 (accepted) / Phase 2 (defensive) | `src/Sync/SyncSubscriberJob.php` (mapFields) |
+| T-DETECTOR-FALSE-POSITIVE-IN-TESTS | With Spatie installed as dev-dep, `class_exists(Multitenancy::class)` returns true in EVERY test → routes Mode A tests through Spatie branch and breaks Phase 1-4 regression coverage. | Researcher verified canonical Spatie v4 "is multitenancy active" check; detector uses verified idiom (not `class_exists`). Phase 5 [[D-13]]. | Phase 5 | `src/Storage/TenancyDetector.php` |
+| T-TENANT-LEAK-VIA-CONTAINER-CACHE | Driver instance survives across tenant boundaries inside queue worker handling Tenant A then Tenant B in sequence. | `scoped()` binding ([[STOR-03]]) verified by two-tenant fixture integration test asserting no API-key bleed. | Phase 5 | `src/StatamicMailerLiteServiceProvider.php` |
+| T-MISSING-TENANT-MIGRATION | Operators forget `tenants:artisan migrate` after addon install → activity log table absent on tenant DB. | README documents the command; `SpatieDatabaseDriver` lenient-read returns `''` when table missing. *(Moot after [[D-15]] pivot — no addon-side Spatie driver ships; lenient-read continues via `AddonYamlDriver`.)* | Phase 5 (moot) | n/a |
+| T-FILE-DRIVER-PATH-TRAVERSAL | `Tenant::current()->id` contains `../` → file path escapes tenant directory. | Moot per [[D-16]] — no SpatieFileDriver ships in v1.0. | Phase 5 (moot) | n/a |
+| T-FILE-MODE-SETTINGS-LEAK | On pure-file-mode multi-tenant Spatie hosts (no eloquent-driver, no per-tenant Statamic boot), settings write to shared `resources/addons/{slug}.yaml` and leak across tenants. | Accepted as v1.0 limitation per [[D-16]]; documented in README. Not detected at runtime. | Phase 5 (accepted) | README (Phase 6 deferred) |
+| T-ENCRYPTED-CAST-FALLBACK | If `'encrypted'` Eloquent cast misbehaves (APP_KEY rotation between writes/reads), reads must degrade to `''` not throw. | Same lenient-read promise as [[D-06]] for the DB-cast path. *(Moot after [[D-15]] pivot — addon delegates to host's settings stack.)* | Phase 5 (moot) | n/a |
+| T-ACTIVITY-LOG-DOUBLE-MIGRATION | OBSOLETED by Phase 6 [[D-04]] / [[D-05]] — no migration ships in v1.0; activity log lives on the submission YAML. The whole migrations-discovery question goes away. | n/a | Phase 5 (moot) | (database/ directory removed in Commit D; standalone activity store removed in Commit E) |
+| T-LOG-WRITE-FAILURE | Activity log write fails mid-`finally`-block (Submission `save()` raises — disk full, host gone, permissions changed at runtime) — the exception would propagate to the HTTP form-submit response and the submitter would see a 500. | `SyncSubscriberJob` wraps the logger call in a try/catch inside the existing finally — on failure, emits `Log::warning('MailerLite activity log write failed — submission sync outcome not persisted', $context)` with `form_handle`, `submission_id`, `sync_status`, `log_error`. Submitter still sees success. Same degrade-arm worked DB-backed (Phase 2), file-backed JSONL (Phase 6 [[D-04]]), and now submission-annotation (Phase 6 [[D-05]]). Regression covered by two Pest tests in `SyncSubscriberJobTest.php` that throw on the FakeSubmission's `save()`. | Phase 6 | `src/Sync/SyncSubscriberJob.php` (finally arm), `tests/Sync/SyncSubscriberJobTest.php` (two regression tests) |
+
+### Plan-level threat IDs (Phase 1)
+
+These appear in Phase 1 plan threat tables but are scoped to individual plans rather than reused cross-phase:
+
+| ID | Origin | Disposition |
+|---|---|---|
+| T-PHASE1-PLAN01-01..04 | `01-01-PLAN.md` plan-01 threat table | Plan-scoped — composer.json integrity, auth tokens, symlink loops, skeleton stache override. All accepted or mitigated within plan-01 acceptance criteria. |
+| T-PHASE1-PLAN02-01..03 | `01-02-PLAN.md` plan-02 threat table | Plan-scoped — interface contract drift (mitigated by `InterfaceContractTest`), exception message info leak (accepted), DoS via TenancyDetector throw (accepted as intended). |
+
+---
+
+## How to use this doc
+
+In code comments, replace verbose explanations with short references. **Always cite the phase** because codes reset per phase (D-11 in Phase 2 is unrelated to D-11 in Phase 4).
+
+```diff
+- // D-11 stateless: a fresh SDK client is constructed per call. NO instance state.
+- // Constructing the SDK client only sets headers/endpoints (cheap); zero benefit to
+- // caching. Phase 5 multi-tenant workers handle Tenant A's job followed by Tenant
+- // B's job in the same process — no client persists between calls so no leakage
+- // surface exists.
++ // Stateless per call — see documentation.md § Phase 2 D-11.
+```
+
+```diff
+- // mitigates T-04-RETRY-STAMPEDE: a frustrated admin rage-clicking Retry on 50
+- // failure rows during a MailerLite outage would otherwise dispatch 50 redundant
+- // jobs in seconds, burning the queue + MailerLite API quota.
++ // Mitigates T-04-RETRY-STAMPEDE — see documentation.md § Threats.
+```
+
+```diff
+- // D-09 + T-API-KEY-LEAK-IN-VALIDATION-ERROR — Laravel ships :input replacement
+- // in validation error messages, which would echo the submitted API key back to
+- // the user in the error toast → key surfaces in browser dev tools / screenshots.
+- // Use :attribute (the field name) only, NEVER :input (the submitted value).
++ // See documentation.md § Phase 3 D-09 + § Threats T-API-KEY-LEAK-IN-VALIDATION-ERROR.
+```
+
+### Cross-references and aliases
+
+- `[[D-XX]]` style intra-document links point to decisions within the **same phase context** unless the link sits inside a `Phase 5` row and explicitly says "Phase 1 [[D-02]]" etc.
+- Requirement codes (`STOR-XX`, `SYNC-XX`, `SET-XX`, `FT-XX`, `FORM-XX`, `CP-XX`, `TEN-XX`, `DIST-XX`) are globally unique across phases and do not reset.
+- Threat codes (`T-XX`) are globally unique and listed together in the §Threats section.
+- SPEC requirements (`SPEC-RX`) only exist for Phase 5.1.
+- Code review findings (`CR-XX`, `WR-XX`, `BL-XX`) are phase-scoped — always cite the phase.
+
+### Adding new codes
+
+When a new decision, threat, requirement, or review finding is captured in a phase CONTEXT.md / SPEC.md / REVIEW.md, add a single-line row here referencing the phase artifact. Keep this doc strictly as a lookup — multi-paragraph rationale stays in the canonical planning files.
diff --git a/package-lock.json b/package-lock.json
index 1f46222..ed635fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,1344 +1,1171 @@
{
- "name": "concept7-statamic-mailerlite",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "dependencies": {
- "@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package"
- },
- "devDependencies": {
- "laravel-vite-plugin": "^2.0.0",
- "vite": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/types": "^7.29.0"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
- "cpu": [
- "mips64el"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.13",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
- "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
- "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
- "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
- "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
- "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
- "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
- "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
- "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
- "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
- "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
- "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
- "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
- "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
- "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
- "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
- "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
- "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
- "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
- "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
- "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
- "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
- "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
- "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
- "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@statamic/cms": {
- "resolved": "vendor/statamic/cms/resources/dist-package",
- "link": true
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "license": "MIT"
- },
- "node_modules/@vitejs/plugin-vue": {
- "version": "6.0.6",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
- "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-rc.13"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
- "vue": "^3.2.25"
- }
- },
- "node_modules/@vue/compiler-core": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
- "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/parser": "^7.29.2",
- "@vue/shared": "3.5.32",
- "entities": "^7.0.1",
- "estree-walker": "^2.0.2",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-dom": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
- "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/compiler-core": "3.5.32",
- "@vue/shared": "3.5.32"
- }
- },
- "node_modules/@vue/compiler-sfc": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
- "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/parser": "^7.29.2",
- "@vue/compiler-core": "3.5.32",
- "@vue/compiler-dom": "3.5.32",
- "@vue/compiler-ssr": "3.5.32",
- "@vue/shared": "3.5.32",
- "estree-walker": "^2.0.2",
- "magic-string": "^0.30.21",
- "postcss": "^8.5.8",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-ssr": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
- "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/compiler-dom": "3.5.32",
- "@vue/shared": "3.5.32"
- }
- },
- "node_modules/@vue/reactivity": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
- "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/shared": "3.5.32"
- }
- },
- "node_modules/@vue/runtime-core": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
- "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/reactivity": "3.5.32",
- "@vue/shared": "3.5.32"
- }
- },
- "node_modules/@vue/runtime-dom": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
- "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/reactivity": "3.5.32",
- "@vue/runtime-core": "3.5.32",
- "@vue/shared": "3.5.32",
- "csstype": "^3.2.3"
- }
- },
- "node_modules/@vue/server-renderer": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
- "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/compiler-ssr": "3.5.32",
- "@vue/shared": "3.5.32"
- },
- "peerDependencies": {
- "vue": "3.5.32"
- }
- },
- "node_modules/@vue/shared": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
- "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/entities": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
- "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
- "license": "BSD-2-Clause",
- "peer": true,
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
- }
- },
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "license": "MIT",
- "peer": true
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/laravel-vite-plugin": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz",
- "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picocolors": "^1.0.0",
- "vite-plugin-full-reload": "^1.1.0"
- },
- "bin": {
- "clean-orphaned-assets": "bin/clean.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^7.0.0"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/rollup": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
- "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.60.1",
- "@rollup/rollup-android-arm64": "4.60.1",
- "@rollup/rollup-darwin-arm64": "4.60.1",
- "@rollup/rollup-darwin-x64": "4.60.1",
- "@rollup/rollup-freebsd-arm64": "4.60.1",
- "@rollup/rollup-freebsd-x64": "4.60.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
- "@rollup/rollup-linux-arm64-gnu": "4.60.1",
- "@rollup/rollup-linux-arm64-musl": "4.60.1",
- "@rollup/rollup-linux-loong64-gnu": "4.60.1",
- "@rollup/rollup-linux-loong64-musl": "4.60.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
- "@rollup/rollup-linux-ppc64-musl": "4.60.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
- "@rollup/rollup-linux-riscv64-musl": "4.60.1",
- "@rollup/rollup-linux-s390x-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-musl": "4.60.1",
- "@rollup/rollup-openbsd-x64": "4.60.1",
- "@rollup/rollup-openharmony-arm64": "4.60.1",
- "@rollup/rollup-win32-arm64-msvc": "4.60.1",
- "@rollup/rollup-win32-ia32-msvc": "4.60.1",
- "@rollup/rollup-win32-x64-gnu": "4.60.1",
- "@rollup/rollup-win32-x64-msvc": "4.60.1",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.16",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
- "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/vite": {
- "version": "7.3.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
- "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vite-plugin-full-reload": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
- "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picocolors": "^1.0.0",
- "picomatch": "^2.3.1"
- }
- },
- "node_modules/vite-plugin-full-reload/node_modules/picomatch": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/vue": {
- "version": "3.5.32",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
- "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vue/compiler-dom": "3.5.32",
- "@vue/compiler-sfc": "3.5.32",
- "@vue/runtime-dom": "3.5.32",
- "@vue/server-renderer": "3.5.32",
- "@vue/shared": "3.5.32"
- },
- "peerDependencies": {
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "vendor/statamic/cms/resources/dist-package": {
- "name": "@statamic/cms",
- "version": "0.0.0",
- "dependencies": {
- "@vitejs/plugin-vue": "^6.0.0"
- }
+ "name": "concept7-statamic-mailerlite-cp",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "concept7-statamic-mailerlite-cp",
+ "version": "0.0.1",
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.0",
+ "vite": "^8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.127.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+ "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
+ "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.13"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
+ "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/shared": "3.5.33",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
+ "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-core": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
+ "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/compiler-core": "3.5.33",
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/compiler-ssr": "3.5.33",
+ "@vue/shared": "3.5.33",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.10",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
+ "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
+ "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
+ "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.33",
+ "@vue/shared": "3.5.33"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
+ "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/reactivity": "3.5.33",
+ "@vue/runtime-core": "3.5.33",
+ "@vue/shared": "3.5.33",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
+ "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.33",
+ "@vue/shared": "3.5.33"
+ },
+ "peerDependencies": {
+ "vue": "3.5.33"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
+ "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.127.0",
+ "@rolldown/pluginutils": "1.0.0-rc.17"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/vite": {
+ "version": "8.0.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rolldown": "1.0.0-rc.17",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
+ "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.33",
+ "@vue/compiler-sfc": "3.5.33",
+ "@vue/runtime-dom": "3.5.33",
+ "@vue/server-renderer": "3.5.33",
+ "@vue/shared": "3.5.33"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
}
+ }
}
+ }
}
diff --git a/package.json b/package.json
index e8aec36..d3e943b 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,16 @@
{
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build"
- },
- "dependencies": {
- "@statamic/cms": "file:./vendor/statamic/cms/resources/dist-package"
- },
- "devDependencies": {
- "laravel-vite-plugin": "^2.0.0",
- "vite": "^7.0.0"
- }
+ "name": "concept7-statamic-mailerlite-cp",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Vite-built CP bundle for the Concept7 Statamic MailerLite addon. Vue is externalized to window.Vue (Statamic's runtime) via the inlined statamicExternals plugin in vite.config.js.",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build && cd ../../.. && php artisan vendor:publish --provider=\"Concept7\\\\StatamicMailerLite\\\\StatamicMailerLiteServiceProvider\" --force",
+ "build:vite": "vite build"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.0",
+ "vite": "^8.0.0"
+ }
}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index e69de29..f3ba9ad 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -0,0 +1,3 @@
+# PHPStan baseline — empty (Phase 4 forward-ref entries resolved as classes land).
+parameters:
+ ignoreErrors: []
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 7680692..b691735 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -8,3 +8,12 @@ parameters:
tmpDir: build/phpstan
stubFiles:
- phpstan-stubs/AddonServiceProvider.stub
+ # The upstream PHPDoc on Statamic\Facades\Addon::get() declares a
+ # non-nullable return, but Statamic\Addons\AddonRepository::get() is
+ # implemented as $this->all()->get($id) — Collection::get returns null
+ # for missing keys. The defensive null-guards in
+ # Concept7\StatamicMailerLite\Storage\AddonYamlDriver (D-12 lenient-read /
+ # D-13 loud-write) target exactly that real-world null branch, so we
+ # opt out of treating PHPDoc-declared types as certain at the strict
+ # comparison level.
+ treatPhpDocTypesAsCertain: false
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..5d55568
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,6 @@
+{
+ "preset": "laravel",
+ "rules": {
+ "fully_qualified_strict_types": false
+ }
+}
diff --git a/public/build/assets/index-YMt838Ji.js b/public/build/assets/index-YMt838Ji.js
new file mode 100644
index 0000000..cdacd02
--- /dev/null
+++ b/public/build/assets/index-YMt838Ji.js
@@ -0,0 +1 @@
+var{Comment:e,EffectScope:t,Fragment:n,KeepAlive:r,ReactiveEffect:i,Static:a,Suspense:o,Teleport:s,Text:c,Transition:l,TransitionGroup:u,VueElement:d,assertNumber:f,callWithAsyncErrorHandling:p,callWithErrorHandling:m,camelize:h,capitalize:g,cloneVNode:_,compatUtils:v,compile:y,computed:b,createApp:x,createBaseVNode:S,createBlock:C,createCommentVNode:w,createElementBlock:T,createElementVNode:E,createHydrationRenderer:ee,createPropsRestProxy:te,createRenderer:ne,createSSRApp:re,createSlots:ie,createStaticVNode:ae,createTextVNode:D,createVNode:O,customRef:oe,defineAsyncComponent:se,defineComponent:ce,defineCustomElement:le,defineEmits:ue,defineExpose:de,defineModel:fe,defineOptions:pe,defineProps:me,defineSSRCustomElement:he,defineSlots:ge,devtools:_e,effect:ve,effectScope:ye,getCurrentInstance:k,getCurrentScope:be,getTransitionRawChildren:xe,guardReactiveProps:Se,h:Ce,handleError:we,hasInjectionContext:Te,hydrate:Ee,hydrateOnIdle:De,hydrateOnInteraction:Oe,hydrateOnMediaQuery:ke,hydrateOnVisible:Ae,initCustomFormatter:je,initDirectivesForSSR:Me,inject:Ne,isMemoSame:Pe,isProxy:Fe,isReactive:Ie,isReadonly:Le,isRef:Re,isRuntimeOnly:ze,isShallow:Be,isVNode:Ve,markRaw:He,mergeDefaults:Ue,mergeModels:We,mergeProps:Ge,nextTick:Ke,normalizeClass:A,normalizeProps:qe,normalizeStyle:Je,onActivated:Ye,onBeforeMount:Xe,onBeforeUnmount:Ze,onBeforeUpdate:Qe,onDeactivated:$e,onErrorCaptured:et,onMounted:j,onRenderTracked:tt,onRenderTriggered:nt,onScopeDispose:rt,onServerPrefetch:it,onUnmounted:M,onUpdated:at,onWatcherCleanup:ot,openBlock:N,popScopeId:st,provide:ct,proxyRefs:lt,pushScopeId:ut,queuePostFlushCb:dt,reactive:ft,readonly:pt,ref:P,registerRuntimeCompiler:mt,render:ht,renderList:F,renderSlot:gt,resolveComponent:I,resolveDirective:_t,resolveDynamicComponent:vt,resolveFilter:yt,resolveTransitionHooks:bt,setBlockTracking:xt,setDevtoolsHook:St,setTransitionHooks:Ct,shallowReactive:wt,shallowReadonly:Tt,shallowRef:Et,ssrContextKey:Dt,ssrUtils:Ot,stop:kt,toDisplayString:L,toHandlerKey:At,toHandlers:jt,toRaw:Mt,toRef:Nt,toRefs:Pt,toValue:Ft,transformVNodeArgs:It,triggerRef:Lt,unref:Rt,useAttrs:zt,useCssModule:Bt,useCssVars:Vt,useHost:Ht,useId:Ut,useModel:Wt,useShadowRoot:Gt,useSSRContext:Kt,useSlots:qt,useTemplateRef:Jt,useTransitionState:Yt,vModelCheckbox:Xt,vModelDynamic:Zt,vModelRadio:Qt,vModelSelect:$t,vModelText:en,vShow:tn,version:nn,warn:rn,watch:an,watchEffect:on,watchPostEffect:sn,watchSyncEffect:cn,withAsyncContext:ln,withCtx:R,withDefaults:un,withDirectives:z,withKeys:B,withMemo:dn,withModifiers:V,withScopeId:fn}=window.Vue,H={class:`max-w-3xl mx-auto py-6 px-4`},U={class:`mb-6`},W={class:`text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100`},G={class:`mt-1 text-sm text-gray-600 dark:text-gray-400`},K={class:`space-y-2`},q={for:`mailerlite-api-key`,class:`block text-sm font-medium text-gray-900 dark:text-gray-100`},J={key:0,class:`ml-2 text-xs font-normal text-gray-500 dark:text-gray-400`},Y=[`placeholder`,`disabled`],X={class:`text-xs text-gray-500 dark:text-gray-400`},pn={class:`mt-6 flex items-center gap-3`},mn=[`disabled`],hn=[`data-state`],gn={class:`mt-8 flex items-center justify-end gap-3 border-t border-gray-200 dark:border-gray-700 pt-6`},_n=[`disabled`],vn={__name:`EditPage`,props:{hasApiKey:{type:Boolean,default:!1},updateUrl:{type:String,required:!0},testConnectionUrl:{type:String,required:!0}},setup(e){let{$axios:t,$toast:n}=k().appContext.config.globalProperties,r=e,i=P(``),a=P(!1),o=P(`idle`),s=null;function c(e){return typeof window<`u`&&typeof window.__==`function`?window.__(e):e}let l=b(()=>o.value===`loading`?c(`Testing...`):c(`Test connection`)),u=b(()=>{switch(o.value){case`idle`:return``;case`loading`:return c(`Checking your API key...`);case`success`:return c(`Connection successful.`);case`error-validation`:return c(`Invalid API key — double-check the value and try again.`);case`error-http`:return c(`Couldn't reach MailerLite — please try again in a moment.`);case`error-empty-key`:return c(`Enter an API key first.`);case`error-rate-limited`:return c(`Too many tests — wait a minute and try again.`);default:return``}}),d=b(()=>{switch(o.value){case`success`:return`text-green-700 dark:text-green-400 font-medium`;case`loading`:return`text-gray-500 dark:text-gray-400`;case`error-validation`:case`error-http`:case`error-empty-key`:case`error-rate-limited`:return`text-red-700 dark:text-red-400 font-medium`;default:return`text-gray-500 dark:text-gray-400`}});function f(){o.value!==`idle`&&o.value!==`loading`&&(m(),o.value=`idle`)}function p(){m(),s=setTimeout(()=>{o.value=`idle`},6e4)}function m(){s!==null&&(clearTimeout(s),s=null)}M(m);async function h(){if(i.value===``){o.value=`error-empty-key`,p();return}o.value=`loading`;try{let e=await t.post(r.testConnectionUrl,{api_key:i.value});if(console.log(`[mailerlite] test-connection response`,{status:e.status,data:e.data}),e.data.success)o.value=`success`;else switch(e.data.errorType){case`validation`:o.value=`error-validation`;break;case`empty-key`:o.value=`error-empty-key`;break;case`http`:o.value=`error-http`;break;default:o.value=`error-http`;break}}catch(e){console.error(`[mailerlite] test-connection failed`,{status:e?.response?.status,data:e?.response?.data,message:e?.message}),e&&e.response&&e.response.status===429?o.value=`error-rate-limited`:e&&e.response&&e.response.data&&e.response.data.errorType===`validation`?o.value=`error-validation`:o.value=`error-http`}finally{p()}}async function g(){if(i.value!==``){a.value=!0;try{await t.patch(r.updateUrl,{api_key:i.value}),n.success(c(`Settings saved.`)),i.value=``,o.value=`idle`,setTimeout(()=>window.location.reload(),600)}catch(e){console.error(`[mailerlite] save failed`,e);let t=e?.response?.status,r=e?.response?.data,i=typeof r==`string`?r.slice(0,120):JSON.stringify(r||{}).slice(0,120);if(t===422){let e=r&&r.errors?Object.values(r.errors).flat().join(` `):c(`Validation failed.`);n.error(e)}else n.error(`Save failed: HTTP ${t??`(no response)`} — ${i}`)}finally{a.value=!1}}}return(t,n)=>(N(),T(`div`,H,[E(`header`,U,[E(`h1`,W,L(c(`MailerLite Settings`)),1),E(`p`,G,L(c(`Configure your MailerLite API key. The key is stored encrypted at rest.`)),1)]),E(`form`,{class:`bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow p-6`,onSubmit:V(g,[`prevent`]),novalidate:``},[E(`div`,K,[E(`label`,q,[D(L(c(`API Key`))+` `,1),e.hasApiKey?(N(),T(`span`,J,` (`+L(c(`A key is currently saved — entering a new value replaces it.`))+`) `,1)):w(``,!0)]),z(E(`input`,{id:`mailerlite-api-key`,"onUpdate:modelValue":n[0]||=e=>i.value=e,type:`password`,autocomplete:`off`,spellcheck:`false`,placeholder:e.hasApiKey?c(`Enter a new key to replace the saved one`):c(`Paste your MailerLite API key`),disabled:o.value===`loading`,class:`block w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 shadow-sm focus:outline-none disabled:opacity-50`,onInput:f},null,40,Y),[[en,i.value]]),E(`p`,X,L(c(`Your MailerLite API key. Stored encrypted at rest.`)),1)]),E(`div`,pn,[E(`button`,{type:`button`,disabled:o.value===`loading`,class:`inline-flex items-center justify-center rounded-md border border-gray-300 dark:border-gray-700 bg-gradient-to-b from-white to-gray-100 dark:from-gray-800 dark:to-gray-900 px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100 shadow-sm hover:to-gray-200 dark:hover:to-gray-900 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed`,onClick:h},L(l.value),9,mn),u.value?(N(),T(`span`,{key:0,class:A([`text-sm`,d.value]),"data-state":o.value},L(u.value),11,hn)):w(``,!0)]),E(`div`,gn,[E(`button`,{type:`submit`,disabled:i.value===``||o.value===`loading`||a.value,class:`inline-flex items-center justify-center rounded-md border border-purple-500 bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-purple-900 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed`},L(a.value?c(`Saving...`):c(`Save`)),9,_n)])],32)]))}},yn={class:`max-w-5xl mx-auto py-6 px-4`},bn={class:`mb-6 flex items-start justify-between gap-4`},xn={class:`text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100`},Sn={class:`mt-1 text-sm text-gray-600 dark:text-gray-400`},Cn=[`href`],wn={key:0,class:`bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow py-12 px-6`},Tn={href:`/cp/forms`,class:`inline-block mt-4`},En={key:1,class:`bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow overflow-hidden`},Dn={class:`bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700`},On=[`aria-sort`],kn={class:`text-gray-400`},An={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},jn={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},Mn={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},Nn=[`aria-sort`],Pn={class:`text-gray-400`},Fn={class:`ps-6`},In=[`href`],Ln={class:`text-xs text-gray-400 mt-0.5`},Rn={key:0,class:`text-sm text-gray-400 dark:text-gray-500`},zn={key:1,class:`flex flex-wrap gap-1`},Bn=[`title`],Vn={key:1,class:`text-sm text-gray-400 dark:text-gray-500`},Hn={key:0,class:`text-sm text-gray-400 dark:text-gray-500`},Un={key:1,class:`text-sm text-red-600 dark:text-red-400 font-medium`},Wn={class:`pe-6`},Gn=[`href`],Kn={__name:`Index`,props:{forms:{type:Array,default:()=>[]},settingsUrl:{type:String,required:!0}},setup(e){let t=e;function r(e){return typeof window<`u`&&typeof window.__==`function`?window.__(e):e}let i=P(`failures`),a=P(`desc`);function o(e){i.value===e?a.value=a.value===`asc`?`desc`:`asc`:(i.value=e,a.value=e===`failures`?`desc`:`asc`)}function s(e){return i.value===e?a.value===`asc`?`↑`:`↓`:``}function c(e){return i.value===e?a.value===`asc`?`ascending`:`descending`:`none`}let l=b(()=>{let e=[...t.forms];return e.sort((e,t)=>{let n=0;return i.value===`failures`?n=e.failure_count_24h-t.failure_count_24h:i.value===`name`&&(n=(e.title||``).localeCompare(t.title||``)),n=a.value===`desc`?-n:n,n===0?(e.title||``).localeCompare(t.title||``):n}),e});function u(e){return e.slice(0,3)}let d=P(0),f=null;j(()=>{f=setInterval(()=>{d.value++},6e4)}),M(()=>{f&&clearInterval(f)});function p(e){if(d.value,!e)return`—`;let t=new Date(e).getTime(),n=Date.now()-t;if(Number.isNaN(t)||n<0)return new Date(e).toLocaleString();let i=Math.floor(n/1e3);if(i<60)return i<=5?r(`just now`):`${i}s ago`;let a=Math.floor(i/60);if(a<60)return`${a} min ago`;let o=Math.floor(a/60);if(o<24)return`${o}h ago`;let s=Math.floor(o/24);return s<30?`${s}d ago`:new Date(e).toLocaleDateString()}function m(e){return e?new Date(e).toLocaleString():``}return(t,i)=>{let a=I(`ui-button`),d=I(`ui-empty-state-item`),f=I(`ui-table-cell`),h=I(`ui-badge`),g=I(`ui-table-row`),_=I(`ui-table`);return N(),T(`div`,yn,[E(`header`,bn,[E(`div`,null,[E(`h1`,xn,L(r(`MailerLite`)),1),E(`p`,Sn,L(r(`Overview of all MailerLite-enabled forms and their sync health.`)),1)]),E(`a`,{href:e.settingsUrl,class:`text-sm text-purple-700 dark:text-purple-300 font-medium hover:underline whitespace-nowrap`},L(r(`Settings →`)),9,Cn)]),e.forms.length===0?(N(),T(`div`,wn,[O(d,{icon:`mailing-list`,heading:r(`No MailerLite-enabled forms yet`),description:r(`Enable MailerLite on a form to start syncing subscribers.`)},{cta:R(()=>[E(`a`,Tn,[O(a,{variant:`default`,size:`base`},{default:R(()=>[D(L(r(`Go to Forms`)),1)]),_:1})])]),_:1},8,[`heading`,`description`])])):(N(),T(`div`,En,[O(_,null,{default:R(()=>[E(`thead`,null,[E(`tr`,Dn,[E(`th`,{scope:`col`,role:`button`,tabindex:`0`,"aria-sort":c(`name`),class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3 ps-6 cursor-pointer select-none focus:outline-none`,onClick:i[0]||=e=>o(`name`),onKeydown:[i[1]||=B(V(e=>o(`name`),[`prevent`]),[`enter`]),i[2]||=B(V(e=>o(`name`),[`prevent`]),[`space`])]},[D(L(r(`Form`))+` `,1),E(`span`,kn,L(s(`name`)),1)],40,On),E(`th`,An,L(r(`Status`)),1),E(`th`,jn,L(r(`Groups`)),1),E(`th`,Mn,L(r(`Last Sync`)),1),E(`th`,{scope:`col`,role:`button`,tabindex:`0`,"aria-sort":c(`failures`),class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3 cursor-pointer select-none focus:outline-none`,onClick:i[3]||=e=>o(`failures`),onKeydown:[i[4]||=B(V(e=>o(`failures`),[`prevent`]),[`enter`]),i[5]||=B(V(e=>o(`failures`),[`prevent`]),[`space`])]},[D(L(r(`Failures (24h)`))+` `,1),E(`span`,Pn,L(s(`failures`)),1)],40,Nn),i[6]||=E(`th`,{scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3 pe-6`},null,-1)])]),E(`tbody`,null,[(N(!0),T(n,null,F(l.value,e=>(N(),C(g,{key:e.handle,class:`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800`},{default:R(()=>[O(f,null,{default:R(()=>[E(`div`,Fn,[E(`a`,{href:e.edit_url,class:`text-sm text-purple-700 dark:text-purple-300 hover:underline font-medium`},L(e.title),9,In),E(`p`,Ln,L(e.handle),1)])]),_:2},1024),O(f,null,{default:R(()=>[O(h,{color:e.enabled?`green`:`default`,text:e.enabled?r(`Enabled`):r(`Disabled`),size:`default`},null,8,[`color`,`text`])]),_:2},1024),O(f,null,{default:R(()=>[e.groups.length===0?(N(),T(`div`,Rn,`—`)):(N(),T(`div`,zn,[(N(!0),T(n,null,F(u(e.groups),e=>(N(),C(h,{key:e,color:`default`,size:`sm`,text:e},null,8,[`text`]))),128)),e.groups.length>3?(N(),C(h,{key:0,color:`default`,size:`sm`,text:`+`+(e.groups.length-3)+` more`},null,8,[`text`])):w(``,!0)]))]),_:2},1024),O(f,null,{default:R(()=>[e.last_sync?(N(),T(`span`,{key:0,title:m(e.last_sync),class:`text-sm text-gray-700 dark:text-gray-300 cursor-default`},L(p(e.last_sync)),9,Bn)):(N(),T(`span`,Vn,`—`))]),_:2},1024),O(f,null,{default:R(()=>[e.failure_count_24h===0?(N(),T(`span`,Hn,`—`)):(N(),T(`span`,Un,L(e.failure_count_24h),1))]),_:2},1024),O(f,null,{default:R(()=>[E(`div`,Wn,[E(`a`,{href:e.activity_url,class:`text-sm text-purple-700 dark:text-purple-300 font-medium hover:underline`},L(r(`View log`)),9,Gn)])]),_:2},1024)]),_:2},1024))),128))])]),_:1})]))])}}},qn={class:`max-w-5xl mx-auto py-6 px-4`},Jn={class:`mb-6`},Yn=[`href`],Z={class:`text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100`},Xn={class:`mt-1 text-sm text-gray-600 dark:text-gray-400`},Zn={key:0,class:`bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow py-12 px-6`},Qn={key:1,class:`bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow overflow-hidden`},$n={class:`bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700`},er={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3 ps-6`},tr={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},nr={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},rr={scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3`},ir={class:`ps-6 text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap`},ar=[`aria-label`,`onClick`],or={key:1,class:`text-sm text-gray-400 dark:text-gray-500`},sr=[`title`],cr={key:1,class:`text-sm text-gray-400 dark:text-gray-500`},lr={class:`pe-6`},ur={class:`px-6 py-4`},dr={class:`text-sm text-gray-600 dark:text-gray-400 mb-3`},fr={key:0,class:`mb-3 flex items-center gap-2`},pr={class:`text-xs text-gray-500 dark:text-gray-400`},mr={key:1,class:`text-xs font-mono bg-gray-100 dark:bg-gray-800 rounded-lg p-3 overflow-auto max-h-80 whitespace-pre-wrap break-all text-gray-800 dark:text-gray-200`},Q={__name:`Show`,props:{form:{type:Object,required:!0},entries:{type:Array,default:()=>[]},dashboardUrl:{type:String,required:!0}},setup(e){let{$axios:t,$toast:r}=k().appContext.config.globalProperties;function i(e){return typeof window<`u`&&typeof window.__==`function`?window.__(e):e}function a(e){return e===`success`?`green`:e===`transient_failure`?`amber`:e===`permanent_failure`?`red`:`default`}function o(e){return e===`success`?i(`Success`):e===`transient_failure`?i(`Failed — retryable`):e===`permanent_failure`?i(`Failed — permanent`):e}function s(e){if(!e)return`—`;let t=new Date(e);if(Number.isNaN(t.getTime()))return e;let n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`}function c(e){return e&&typeof e==`object`&&Object.keys(e).length>0}function l(e){let t=JSON.stringify(e||{});return t.length>80?t.slice(0,80)+`…`:t}function u(e){return e?e.length>120?e.slice(0,120)+`…`:e:``}let d=P(!1),f=P(null);function p(e){f.value=e,d.value=!0}let m=P({});async function h(e){if(!m.value[e.id]){m.value[e.id]=!0;try{await t.post(e.retry_url),r.success(i(`Retry dispatched.`))}catch(e){e&&e.response&&e.response.status===429?r.error(i(`Retry rate limit reached — try again in a moment.`)):r.error(i(`Couldn't dispatch retry — please try again.`))}finally{m.value[e.id]=!1}}}return(t,r)=>{let g=I(`ui-empty-state-item`),_=I(`ui-table-cell`),v=I(`ui-badge`),y=I(`ui-button`),b=I(`ui-table-row`),x=I(`ui-table`),S=I(`ui-modal`);return N(),T(`div`,qn,[E(`header`,Jn,[E(`a`,{href:e.dashboardUrl,class:`inline-block mb-2 text-sm text-purple-700 dark:text-purple-300 font-medium hover:underline`},L(i(`← All forms`)),9,Yn),E(`h1`,Z,L(i(`Activity Log`))+` — `+L(e.form.title),1),E(`p`,Xn,L(i(`Every sync attempt for this form. Failures can be retried individually.`)),1)]),e.entries.length===0?(N(),T(`div`,Zn,[O(g,{icon:`mailing-list`,heading:i(`No sync activity yet`),description:i(`Submit the form to populate this view.`)},null,8,[`heading`,`description`])])):(N(),T(`div`,Qn,[O(x,null,{default:R(()=>[E(`thead`,null,[E(`tr`,$n,[E(`th`,er,L(i(`Time`)),1),E(`th`,tr,L(i(`Status`)),1),E(`th`,nr,L(i(`Payload`)),1),E(`th`,rr,L(i(`Error`)),1),r[1]||=E(`th`,{scope:`col`,class:`text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300 p-3 pe-6`},null,-1)])]),E(`tbody`,null,[(N(!0),T(n,null,F(e.entries,e=>(N(),C(b,{key:e.id,class:`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800`},{default:R(()=>[O(_,null,{default:R(()=>[E(`span`,ir,L(s(e.created_at)),1)]),_:2},1024),O(_,null,{default:R(()=>[O(v,{color:a(e.status),text:o(e.status),size:`default`},null,8,[`color`,`text`])]),_:2},1024),O(_,null,{default:R(()=>[c(e.payload)?(N(),T(`button`,{key:0,type:`button`,class:`font-mono text-xs text-gray-600 dark:text-gray-400 text-left bg-transparent border-0 p-0 cursor-pointer focus:outline-none`,"aria-label":i(`Show full sync payload`),onClick:t=>p(e)},L(l(e.payload)),9,ar)):(N(),T(`span`,or,`—`))]),_:2},1024),O(_,null,{default:R(()=>[e.error_message?(N(),T(`span`,{key:0,class:`text-xs text-red-600 dark:text-red-400`,title:e.error_message},L(u(e.error_message)),9,sr)):(N(),T(`span`,cr,`—`))]),_:2},1024),O(_,null,{default:R(()=>[E(`div`,lr,[e.can_retry?(N(),C(y,{key:0,variant:`default`,size:`sm`,loading:!!m.value[e.id],disabled:!!m.value[e.id],onClick:t=>h(e)},{default:R(()=>[D(L(i(`Retry`)),1)]),_:1},8,[`loading`,`disabled`,`onClick`])):w(``,!0)])]),_:2},1024)]),_:2},1024))),128))])]),_:1})])),O(S,{open:d.value,"onUpdate:open":r[0]||=e=>d.value=e,title:i(`Sync Payload`),dismissible:``},{default:R(()=>[E(`div`,ur,[E(`p`,dr,L(i(`Redacted payload sent to MailerLite. Sensitive fields are masked.`)),1),f.value?(N(),T(`div`,fr,[O(v,{color:a(f.value.status),text:o(f.value.status),size:`default`},null,8,[`color`,`text`]),E(`span`,pr,L(s(f.value.created_at)),1)])):w(``,!0),f.value?(N(),T(`pre`,mr,L(JSON.stringify(f.value.payload||{},null,2)),1)):w(``,!0)])]),_:1},8,[`open`,`title`])])}}};Statamic.$inertia.register(`MailerLiteSettings`,vn),Statamic.$inertia.register(`MailerLiteDashboard`,Kn),Statamic.$inertia.register(`MailerLiteActivity`,Q);function $(){if(typeof window>`u`||window.location.hash!==`#mailerlite`)return;let e=document.querySelector(`[data-handle="mailerlite"]`)||document.getElementById(`mailerlite`);e&&(e.scrollIntoView({behavior:`smooth`,block:`start`}),e.classList.add(`ring-2`,`ring-offset-2`,`ring-blue-500`,`rounded-md`,`transition-all`),setTimeout(()=>{e.classList.remove(`ring-2`,`ring-offset-2`,`ring-blue-500`,`rounded-md`)},1500))}typeof window<`u`&&(document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,$):setTimeout($,0),window.addEventListener(`popstate`,$),typeof Statamic<`u`&&Statamic.$inertia&&typeof Statamic.$inertia.on==`function`&&Statamic.$inertia.on(`navigate`,$));
\ No newline at end of file
diff --git a/public/build/manifest.json b/public/build/manifest.json
new file mode 100644
index 0000000..39e9478
--- /dev/null
+++ b/public/build/manifest.json
@@ -0,0 +1,8 @@
+{
+ "resources/js/cp/index.js": {
+ "file": "assets/index-YMt838Ji.js",
+ "name": "index",
+ "src": "resources/js/cp/index.js",
+ "isEntry": true
+ }
+}
\ No newline at end of file
diff --git a/resources/blueprints/settings.yaml b/resources/blueprints/settings.yaml
index 96a1536..f3e5421 100644
--- a/resources/blueprints/settings.yaml
+++ b/resources/blueprints/settings.yaml
@@ -1,12 +1,15 @@
tabs:
main:
sections:
- - display: API
+ -
+ display: 'MailerLite API'
+ instructions: 'Your MailerLite API key. Create one under MailerLite → Integrations → API. The key is stored in the addon settings YAML on disk — keep your storage permissions tight.'
fields:
- - handle: api_key
+ -
+ handle: api_key
field:
type: text
- display: API Key
- instructions: Your MailerLite API key.
+ display: 'API key'
+ instructions: 'Paste the API token from MailerLite. Saving without an API key is allowed but no submissions will sync until one is provided.'
input_type: password
- validate: required
+ width: 100
diff --git a/resources/dist/build/assets/addon-e4Zwj-7O.js b/resources/dist/build/assets/addon-e4Zwj-7O.js
deleted file mode 100644
index 057fa66..0000000
--- a/resources/dist/build/assets/addon-e4Zwj-7O.js
+++ /dev/null
@@ -1 +0,0 @@
-const S=window.Vue,{BaseTransition:M,BaseTransitionPropsValidators:E,Comment:I,DeprecationTypes:D,EffectScope:A,ErrorCodes:L,ErrorTypeStrings:H,Fragment:F,KeepAlive:B,ReactiveEffect:V,Static:N,Suspense:z,Teleport:G,Text:O,TrackOpTypes:U,Transition:q,TransitionGroup:K,TriggerOpTypes:W,VueElement:j,__esModule:$,assertNumber:Q,callWithAsyncErrorHandling:Y,callWithErrorHandling:J,camelize:X,capitalize:Z,cloneVNode:ee,compatUtils:te,compile:oe,computed:T,createApp:re,createBlock:l,createCommentVNode:ne,createElementBlock:u,createElementVNode:ie,createHydrationRenderer:ae,createPropsRestProxy:se,createRenderer:le,createSSRApp:de,createSlots:ce,createStaticVNode:ue,createTextVNode:d,createVNode:i,customRef:pe,defineAsyncComponent:me,defineComponent:ge,defineCustomElement:Ce,defineEmits:he,defineExpose:fe,defineModel:Se,defineOptions:Te,defineProps:ye,defineSSRCustomElement:we,defineSlots:Pe,devtools:ve,effect:be,effectScope:Re,getCurrentInstance:xe,getCurrentScope:ke,getCurrentWatcher:_e,getTransitionRawChildren:Me,guardReactiveProps:Ee,h:Ie,handleError:De,hasInjectionContext:Ae,hydrate:Le,hydrateOnIdle:He,hydrateOnInteraction:Fe,hydrateOnMediaQuery:Be,hydrateOnVisible:Ve,initCustomFormatter:Ne,initDirectivesForSSR:ze,inject:Ge,isMemoSame:Oe,isProxy:Ue,isReactive:qe,isReadonly:Ke,isRef:We,isRuntimeOnly:je,isShallow:$e,isVNode:Qe,markRaw:Ye,mergeDefaults:Je,mergeModels:Xe,mergeProps:Ze,nextTick:et,nodeOps:tt,normalizeClass:ot,normalizeProps:rt,normalizeStyle:nt,onActivated:it,onBeforeMount:at,onBeforeUnmount:st,onBeforeUpdate:lt,onDeactivated:dt,onErrorCaptured:ct,onMounted:ut,onRenderTracked:pt,onRenderTriggered:mt,onScopeDispose:gt,onServerPrefetch:Ct,onUnmounted:ht,onUpdated:ft,onWatcherCleanup:St,openBlock:a,patchProp:Tt,popScopeId:yt,provide:wt,proxyRefs:Pt,pushScopeId:vt,queuePostFlushCb:bt,reactive:Rt,readonly:xt,ref:kt,registerRuntimeCompiler:_t,render:Mt,renderList:Et,renderSlot:It,resolveComponent:Dt,resolveDirective:At,resolveDynamicComponent:Lt,resolveFilter:Ht,resolveTransitionHooks:Ft,setBlockTracking:Bt,setDevtoolsHook:Vt,setTransitionHooks:Nt,shallowReactive:zt,shallowReadonly:Gt,shallowRef:Ot,ssrContextKey:Ut,ssrUtils:qt,stop:Kt,toDisplayString:s,toHandlerKey:Wt,toHandlers:jt,toRaw:$t,toRef:Qt,toRefs:Yt,toValue:Jt,transformVNodeArgs:Xt,triggerRef:Zt,unref:t,useAttrs:eo,useCssModule:to,useCssVars:oo,useHost:ro,useId:no,useModel:io,useSSRContext:ao,useShadowRoot:so,useSlots:lo,useTemplateRef:co,useTransitionState:uo,vModelCheckbox:po,vModelDynamic:mo,vModelRadio:go,vModelSelect:Co,vModelText:ho,vShow:fo,version:So,warn:To,watch:yo,watchEffect:wo,watchPostEffect:Po,watchSyncEffect:vo,withAsyncContext:bo,withCtx:r,withDefaults:Ro,withDirectives:xo,withKeys:ko,withMemo:_o,withModifiers:Mo,withScopeId:Eo}=S,{Alert:Io,AuthCard:Do,Avatar:Ao,Badge:p,Button:y,ButtonGroup:Lo,Calendar:Ho,Card:Fo,CardList:Bo,CardListItem:Vo,CardPanel:No,CharacterCounter:zo,Checkbox:Go,CheckboxGroup:Oo,CodeEditor:Uo,Combobox:qo,CommandPaletteItem:Ko,ConfirmationModal:Wo,Context:jo,ContextFooter:$o,ContextHeader:Qo,ContextItem:Yo,ContextLabel:Jo,ContextMenu:Xo,ContextSeparator:Zo,CreateForm:er,DatePicker:tr,DateRangePicker:or,Description:rr,DocsCallout:nr,DragHandle:ir,Dropdown:ar,DropdownItem:m,DropdownLabel:sr,DropdownMenu:lr,DropdownSeparator:dr,DropdownFooter:cr,DropdownHeader:ur,Editable:pr,ErrorMessage:mr,EmptyStateItem:gr,EmptyStateMenu:Cr,Field:hr,Header:w,Heading:fr,HoverCard:Sr,Icon:Tr,Input:yr,InputGroup:wr,InputGroupAppend:Pr,InputGroupPrepend:vr,Label:br,Listing:P,ListingCustomizeColumns:Rr,ListingFilters:xr,ListingHeaderCell:kr,ListingPagination:_r,ListingPresets:Mr,ListingPresetTrigger:Er,ListingRowActions:Ir,ListingSearch:Dr,ListingTable:Ar,ListingTableBody:Lr,ListingTableHead:Hr,ListingToggleAll:Fr,LivePreview:Br,LivePreviewPopout:Vr,MiddleEllipsis:Nr,Modal:zr,ModalClose:Gr,ModalTitle:Or,Pagination:Ur,Panel:qr,PanelFooter:Kr,PanelHeader:Wr,Popover:jr,PublishComponents:$r,PublishContainer:Qr,publishContextKey:Yr,injectPublishContext:Jr,PublishField:Xr,PublishFields:Zr,PublishFieldsProvider:en,PublishForm:tn,PublishLocalizations:on,PublishSections:rn,PublishTabs:nn,Radio:an,RadioGroup:sn,Select:ln,Separator:dn,Slider:cn,Skeleton:un,SplitterGroup:pn,SplitterPanel:mn,SplitterResizeHandle:gn,StatusIndicator:Cn,Subheading:hn,Switch:fn,TabContent:Sn,Stack:Tn,StackClose:yn,StackHeader:wn,StackFooter:Pn,StackContent:vn,Table:bn,TableCell:Rn,TableColumn:xn,TableColumns:kn,TableRow:_n,TableRows:Mn,TabList:En,TabProvider:In,Tabs:Dn,TabTrigger:An,Text:Ln,Textarea:Hn,TimePicker:Fn,ToggleGroup:Bn,ToggleItem:Vn,Widget:Nn,registerIconSet:zn,registerIconSetFromStrings:Gn}=__STATAMIC__.ui,{Form:On,Head:Un,Link:v,router:g,toggleArchitecturalBackground:qn,useArchitecturalBackground:Kn,useForm:Wn,usePoll:jn}=__STATAMIC__.inertia,b={class:"max-w-page mx-auto"},R={key:0,class:"card p-6 text-center text-gray-500"},x={__name:"Index",props:{configs:{type:Array,required:!0},initialColumns:{type:Array,required:!0},title:{type:String,required:!0},createUrl:{type:String,required:!0}},setup(n){const C=n,h=T(()=>C.configs.length===0),c=()=>g.reload();function f(e){confirm(__("Are you sure you want to delete this form config?"))&&g.delete(e.delete_url,{onSuccess:()=>c()})}return(e,k)=>(a(),u("div",b,[i(t(w),{title:n.title,icon:"email"},{default:r(()=>[i(t(y),{href:n.createUrl,text:e.__("Create"),variant:"primary"},null,8,["href","text"])]),_:1},8,["title"]),h.value?(a(),u("div",R,s(e.__("No form configs found. Create one to get started.")),1)):(a(),l(t(P),{key:1,items:n.configs,columns:n.initialColumns,"allow-search":!1,"allow-customizing-columns":!1,onRefreshing:c},{"cell-title":r(({row:o})=>[i(t(v),{href:o.edit_url},{default:r(()=>[d(s(o.title),1)]),_:2},1032,["href"])]),"cell-enabled":r(({row:o})=>[o.enabled?(a(),l(t(p),{key:0,color:"green"},{default:r(()=>[d(s(e.__("Yes")),1)]),_:1})):(a(),l(t(p),{key:1,color:"gray"},{default:r(()=>[d(s(e.__("No")),1)]),_:1}))]),"prepended-row-actions":r(({row:o})=>[i(t(m),{text:e.__("Edit"),icon:"cog",href:o.edit_url},null,8,["text","href"]),i(t(m),{text:e.__("Delete"),icon:"trash",variant:"destructive",onClick:_=>f(o)},null,8,["text","onClick"])]),_:1},8,["items","columns"]))]))}};Statamic.booting(()=>{Statamic.$inertia.register("statamic-mailerlite::FormConfig/Index",x)});
diff --git a/resources/dist/build/manifest.json b/resources/dist/build/manifest.json
deleted file mode 100644
index 25faecd..0000000
--- a/resources/dist/build/manifest.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "resources/js/addon.js": {
- "file": "assets/addon-e4Zwj-7O.js",
- "name": "addon",
- "src": "resources/js/addon.js",
- "isEntry": true
- }
-}
\ No newline at end of file
diff --git a/resources/js/addon.js b/resources/js/addon.js
deleted file mode 100644
index c760283..0000000
--- a/resources/js/addon.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Index from './pages/FormConfig/Index.vue';
-
-Statamic.booting(() => {
- Statamic.$inertia.register('statamic-mailerlite::FormConfig/Index', Index);
-});
diff --git a/resources/js/cp/Activity/Show.vue b/resources/js/cp/Activity/Show.vue
new file mode 100644
index 0000000..6bf032f
--- /dev/null
+++ b/resources/js/cp/Activity/Show.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Time') }}
+
+
+ {{ __('Status') }}
+
+
+ {{ __('Payload') }}
+
+
+ {{ __('Error') }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatTime(entry.created_at) }}
+
+
+
+
+
+
+
+ {{ truncatePayload(entry.payload) }}
+
+ —
+
+
+
+ {{ truncateError(entry.error_message) }}
+
+ —
+
+
+
+
+ {{ __('Retry') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Redacted payload sent to MailerLite. Sensitive fields are masked.') }}
+
+
+
+
+ {{ formatTime(selectedEntry.created_at) }}
+
+
+
{{ JSON.stringify(selectedEntry.payload || {}, null, 2) }}
+
+
+
+
+
+
diff --git a/resources/js/cp/Dashboard/Index.vue b/resources/js/cp/Dashboard/Index.vue
new file mode 100644
index 0000000..20b9274
--- /dev/null
+++ b/resources/js/cp/Dashboard/Index.vue
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Form') }} {{ sortIndicator('name') }}
+
+
+ {{ __('Status') }}
+
+
+ {{ __('Groups') }}
+
+
+ {{ __('Last Sync') }}
+
+
+ {{ __('Failures (24h)') }} {{ sortIndicator('failures') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
+ {{ relativeTime(row.last_sync) }}
+
+ —
+
+
+ —
+ {{ row.failure_count_24h }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/cp/index.js b/resources/js/cp/index.js
new file mode 100644
index 0000000..76e3854
--- /dev/null
+++ b/resources/js/cp/index.js
@@ -0,0 +1,68 @@
+/**
+ * Addon CP Vite entry point.
+ *
+ * Registers Vue components into Statamic's Inertia addon registry
+ * (vendor resources/js/components/Inertia.ts:6 + bootstrap/statamic.js:218-220).
+ * Each registered name MUST match the controller's Inertia::render(...) call.
+ */
+
+import DashboardIndex from './Dashboard/Index.vue';
+import ActivityShow from './Activity/Show.vue';
+
+Statamic.$inertia.register('MailerLiteDashboard', DashboardIndex);
+Statamic.$inertia.register('MailerLiteActivity', ActivityShow);
+
+/**
+ * D-15 + CP-03 — Fragment focus pulse.
+ *
+ * When the form-edit URL carries the #mailerlite hash, scroll the MailerLite
+ * blueprint section into view and apply a 1.5s blue ring pulse.
+ *
+ * The selector [data-handle="mailerlite"] targets Statamic's blueprint section
+ * heading (per 04-RESEARCH Pattern 7 fallback). If the actual DOM contract
+ * differs in this Statamic 6 minor version, the executor flags it as Rule 1
+ * deviation and uses the discovered selector — the behavior contract (scroll
+ * + 1.5s ring pulse on hash match) stays.
+ *
+ * Fires on:
+ * - DOMContentLoaded (initial page load with hash already in URL)
+ * - popstate (browser back/forward — hash changes preserved)
+ * - Statamic Inertia navigate event (SPA navigation between pages)
+ */
+function focusMailerLiteFragment() {
+ if (typeof window === 'undefined') return;
+ if (window.location.hash !== '#mailerlite') return;
+
+ // Try the documented data-handle selector first; fall back to id="mailerlite"
+ // for hosts that wrap the section in a custom anchor.
+ const el = document.querySelector('[data-handle="mailerlite"]')
+ || document.getElementById('mailerlite');
+
+ if (!el) return;
+
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ el.classList.add('ring-2', 'ring-offset-2', 'ring-blue-500', 'rounded-md', 'transition-all');
+ setTimeout(() => {
+ el.classList.remove('ring-2', 'ring-offset-2', 'ring-blue-500', 'rounded-md');
+ }, 1500);
+}
+
+if (typeof window !== 'undefined') {
+ // Initial page load — DOMContentLoaded fires once.
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', focusMailerLiteFragment);
+ } else {
+ // Document already parsed — schedule one tick to let any pending Vue mounts
+ // populate the section.
+ setTimeout(focusMailerLiteFragment, 0);
+ }
+
+ // Browser back/forward — popstate retains the URL hash.
+ window.addEventListener('popstate', focusMailerLiteFragment);
+
+ // Inertia SPA navigation — Statamic.$inertia exposes an `on` listener for
+ // navigate-style events. Guard for availability across Statamic versions.
+ if (typeof Statamic !== 'undefined' && Statamic.$inertia && typeof Statamic.$inertia.on === 'function') {
+ Statamic.$inertia.on('navigate', focusMailerLiteFragment);
+ }
+}
diff --git a/resources/js/pages/FormConfig/Index.vue b/resources/js/pages/FormConfig/Index.vue
deleted file mode 100644
index 2c3c529..0000000
--- a/resources/js/pages/FormConfig/Index.vue
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
- {{ __('No form configs found. Create one to get started.') }}
-
-
-
-
- {{ config.title }}
-
-
- {{ __('Yes') }}
- {{ __('No') }}
-
-
-
-
-
-
-
-
diff --git a/routes/cp.php b/routes/cp.php
index 6d32849..35299ff 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -1,13 +1,46 @@
name('statamic-mailerlite.')->group(function () {
- Route::get('form-config', [FormConfigController::class, 'index'])->name('form-config.index');
- Route::get('form-config/create', [FormConfigController::class, 'create'])->name('form-config.create');
- Route::post('form-config', [FormConfigController::class, 'store'])->name('form-config.store');
- Route::get('form-config/{id}', [FormConfigController::class, 'edit'])->name('form-config.edit');
- Route::patch('form-config/{id}', [FormConfigController::class, 'update'])->name('form-config.update');
- Route::delete('form-config/{id}', [FormConfigController::class, 'destroy'])->name('form-config.destroy');
+/*
+|--------------------------------------------------------------------------
+| MailerLite addon CP routes
+|--------------------------------------------------------------------------
+| Statamic's AddonServiceProvider::bootRoutes() (vendor:548-556) auto-loads
+| this file and wraps it with the /cp/ URL prefix and the CP authentication
+| middleware group automatically.
+|
+| DO NOT re-add those — double-prefixing breaks route resolution.
+|
+| The permission middleware (D-12 + SET-05) gates all routes. Statamic's
+| Gate::after hook (vendor AuthServiceProvider:147-162) grants super-users
+| automatic bypass on this Statamic-registered permission.
+|
+| Settings UI is provided by Statamic's native addon-settings system at
+| Tools → Addons (auto-rendered from resources/blueprints/settings.yaml);
+| no dedicated /cp/mailerlite/settings route is declared here.
+*/
+
+Route::middleware(['can:configure mailerlite'])->group(function (): void {
+ // CP-01 + D-13 — top-level dashboard.
+ Route::get('mailerlite', [DashboardController::class, 'index'])
+ ->name('mailerlite.dashboard');
+
+ // CP-04 — per-form activity log read view.
+ Route::get('mailerlite/forms/{form}/activity', [ActivityController::class, 'show'])
+ ->name('mailerlite.activity.show');
+
+ // CP-05 — retry endpoint. URL segment is `{log}` not `{entry}` because
+ // Statamic's RouteServiceProvider::bindEntries() (vendor:80-100) registers
+ // a global `Routes::bind('entry', ...)` that resolves to a Statamic Entry
+ // model on CP routes — Entry::find($uuid) returns null for our activity-log
+ // UUID, raising NotFoundHttpException before the controller ever runs.
+ Route::post('mailerlite/forms/{form}/activity/{log}/retry', [RetryController::class, 'store'])
+ ->middleware('throttle:mailerlite-retry')
+ ->name('mailerlite.activity.retry');
});
diff --git a/src/Activity/ActivityLogEntry.php b/src/Activity/ActivityLogEntry.php
new file mode 100644
index 0000000..5897102
--- /dev/null
+++ b/src/Activity/ActivityLogEntry.php
@@ -0,0 +1,166 @@
+ $payload
+ */
+ public function __construct(
+ public readonly string $id,
+ public readonly string $form_handle,
+ public readonly ?string $submission_id,
+ public readonly string $status,
+ public readonly array $payload,
+ public readonly ?string $error_message,
+ public readonly Carbon $created_at,
+ ) {}
+
+ /**
+ * All attempts from a given submission, oldest-first (insertion order).
+ *
+ * @return Collection
+ */
+ public static function forSubmission(Submission $submission): Collection
+ {
+ $sync = $submission->get('mailerlite_sync');
+ if (! is_array($sync)) {
+ return new Collection;
+ }
+
+ $attempts = $sync['attempts'] ?? [];
+ if (! is_array($attempts)) {
+ return new Collection;
+ }
+
+ $formHandle = $submission->form()->handle();
+ $submissionId = (string) $submission->id();
+
+ return collect($attempts)
+ ->map(static fn (mixed $attempt): ?self => self::fromAttempt(
+ attempt: $attempt,
+ formHandle: $formHandle,
+ submissionId: $submissionId,
+ ))
+ ->filter()
+ ->values();
+ }
+
+ /**
+ * All attempts across all submissions of one form.
+ *
+ * @return Collection
+ */
+ public static function forForm(string $formHandle): Collection
+ {
+ $form = Form::find($formHandle);
+ if ($form === null) {
+ return new Collection;
+ }
+
+ /** @var Collection $entries */
+ $entries = collect($form->submissions())
+ ->flatMap(static fn (Submission $submission): Collection => self::forSubmission($submission));
+
+ return $entries->values();
+ }
+
+ /**
+ * All attempts across all forms. Used by the dashboard health summary.
+ *
+ * @return Collection
+ */
+ public static function all(): Collection
+ {
+ /** @var Collection $entries */
+ $entries = collect(Form::all())
+ ->flatMap(static fn ($form): Collection => self::forForm((string) $form->handle()));
+
+ return $entries->values();
+ }
+
+ /**
+ * Find an attempt by its UUID. Scans the given form if provided, all forms otherwise.
+ */
+ public static function find(string $id, ?string $formHandle = null): ?self
+ {
+ $collection = $formHandle !== null
+ ? self::forForm($formHandle)
+ : self::all();
+
+ return $collection->firstWhere('id', $id);
+ }
+
+ /**
+ * Project a single attempt array (as stored in `mailerlite_sync.attempts[]`)
+ * into a read-side DTO. Returns null on malformed/incomplete attempts.
+ */
+ public static function fromAttempt(
+ mixed $attempt,
+ string $formHandle,
+ ?string $submissionId,
+ ): ?self {
+ if (! is_array($attempt)) {
+ return null;
+ }
+
+ $idRaw = $attempt['id'] ?? null;
+ $id = is_string($idRaw) ? $idRaw : '';
+ if ($id === '') {
+ return null;
+ }
+
+ $createdAtRaw = $attempt['attempted_at'] ?? null;
+ $createdAt = is_string($createdAtRaw) && $createdAtRaw !== ''
+ ? Carbon::parse($createdAtRaw)
+ : Carbon::now();
+
+ $payloadRaw = $attempt['payload'] ?? [];
+ /** @var array $payload */
+ $payload = is_array($payloadRaw) ? $payloadRaw : [];
+
+ $errorMessageRaw = $attempt['error_message'] ?? null;
+ $errorMessage = is_string($errorMessageRaw) ? $errorMessageRaw : null;
+
+ return new self(
+ id: $id,
+ form_handle: $formHandle,
+ submission_id: $submissionId,
+ status: (string) ($attempt['status'] ?? ''),
+ payload: $payload,
+ error_message: $errorMessage,
+ created_at: $createdAt,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'form_handle' => $this->form_handle,
+ 'submission_id' => $this->submission_id,
+ 'status' => $this->status,
+ 'payload' => $this->payload,
+ 'error_message' => $this->error_message,
+ 'created_at' => $this->created_at->toIso8601String(),
+ ];
+ }
+}
diff --git a/src/Activity/ActivityLogger.php b/src/Activity/ActivityLogger.php
new file mode 100644
index 0000000..d361d58
--- /dev/null
+++ b/src/Activity/ActivityLogger.php
@@ -0,0 +1,91 @@
+ $payload Pre-redaction.
+ */
+ public function log(
+ string $formHandle,
+ ?string $submissionId,
+ string $status,
+ array $payload,
+ ?string $errorMessage,
+ ): void {
+ if ($submissionId === null || $submissionId === '') {
+ Log::warning('MailerLite activity log skipped — missing submission id', [
+ 'form_handle' => $formHandle,
+ 'status' => $status,
+ ]);
+
+ return;
+ }
+
+ $form = Form::find($formHandle);
+ if ($form === null) {
+ Log::warning('MailerLite activity log skipped — unknown form', [
+ 'form_handle' => $formHandle,
+ 'submission_id' => $submissionId,
+ 'status' => $status,
+ ]);
+
+ return;
+ }
+
+ $submission = $form->submission($submissionId);
+ if ($submission === null) {
+ // form.store:false, deleted submission, or retry path with synthetic id.
+ // No on-disk YAML to annotate — log to Laravel and move on.
+ Log::warning('MailerLite activity log skipped — submission not on disk', [
+ 'form_handle' => $formHandle,
+ 'submission_id' => $submissionId,
+ 'status' => $status,
+ ]);
+
+ return;
+ }
+
+ $sync = $submission->get('mailerlite_sync');
+ $existingAttempts = (is_array($sync) && isset($sync['attempts']) && is_array($sync['attempts']))
+ ? $sync['attempts']
+ : [];
+
+ $existingAttempts[] = [
+ 'id' => (string) Str::uuid(),
+ 'attempted_at' => Carbon::now()->toIso8601String(),
+ 'status' => $status,
+ 'payload' => $this->redactor->redact($payload),
+ 'error_message' => $errorMessage,
+ ];
+
+ $submission->set('mailerlite_sync', ['attempts' => $existingAttempts]);
+ // save() — the addon's own listener subscribes to SubmissionCreated (only fires
+ // on $isNew), not SubmissionSaved, so this re-save does not re-dispatch a sync.
+ $submission->save();
+ }
+}
diff --git a/src/Activity/PayloadRedactor.php b/src/Activity/PayloadRedactor.php
new file mode 100644
index 0000000..9675cd0
--- /dev/null
+++ b/src/Activity/PayloadRedactor.php
@@ -0,0 +1,23 @@
+ $payload
+ * @return array
+ */
+ public function redact(array $payload): array
+ {
+ return array_filter(
+ $payload,
+ fn (string|int $key): bool => preg_match('/password|token|secret|card|cvv/i', (string) $key) === 0,
+ ARRAY_FILTER_USE_KEY,
+ );
+ }
+}
diff --git a/src/CP/ActivityController.php b/src/CP/ActivityController.php
new file mode 100644
index 0000000..e07df77
--- /dev/null
+++ b/src/CP/ActivityController.php
@@ -0,0 +1,52 @@
+handle();
+
+ // Cap at 500 rows to bound the Inertia payload.
+ $entries = ActivityLogEntry::forForm($handle)
+ ->sortByDesc(fn (ActivityLogEntry $entry): int => $entry->created_at->getTimestamp())
+ ->take(500)
+ ->map(fn (ActivityLogEntry $entry): array => [
+ 'id' => $entry->id,
+ 'created_at' => $entry->created_at->toIso8601String(),
+ 'status' => $entry->status,
+ 'payload' => $entry->payload,
+ 'error_message' => $entry->error_message,
+ 'can_retry' => in_array($entry->status, ['transient_failure', 'permanent_failure'], true),
+ // Route segment is `{log}` not `{entry}` — `entry` is bound to Statamic Entry model.
+ 'retry_url' => cp_route('mailerlite.activity.retry', ['form' => $handle, 'log' => $entry->id]),
+ ])
+ ->values()
+ ->all();
+
+ return Inertia::render('MailerLiteActivity', [
+ 'form' => [
+ 'handle' => $handle,
+ 'title' => $form->title(),
+ ],
+ 'entries' => $entries,
+ 'dashboardUrl' => cp_route('mailerlite.dashboard'),
+ ]);
+ }
+}
diff --git a/src/CP/DashboardController.php b/src/CP/DashboardController.php
new file mode 100644
index 0000000..9a37303
--- /dev/null
+++ b/src/CP/DashboardController.php
@@ -0,0 +1,48 @@
+allByFormHandle();
+ $allConfigs = $driver->allFormConfigs();
+
+ $forms = Form::all()
+ ->map(function ($form) use ($allConfigs, $stats): array {
+ $handle = $form->handle();
+ $config = $allConfigs[$handle] ?? [];
+ $row = $stats->get($handle);
+
+ return [
+ 'handle' => $handle,
+ 'title' => $form->title(),
+ 'enabled' => (bool) ($config['enabled'] ?? false),
+ 'groups' => array_values($config['groups'] ?? []),
+ 'last_sync' => $row !== null ? $row->last_sync : null,
+ 'failure_count_24h' => $row !== null ? $row->failure_count_24h : 0,
+ 'activity_url' => cp_route('mailerlite.activity.show', ['form' => $handle]),
+ // Use route() not cp_route() — cp_route prepends 'statamic.cp.' producing a double prefix.
+ 'edit_url' => route('statamic.cp.forms.edit', ['form' => $handle]).'#mailerlite',
+ ];
+ })
+ ->values()
+ ->all();
+
+ return Inertia::render('MailerLiteDashboard', [
+ 'forms' => $forms,
+ ]);
+ }
+}
diff --git a/src/CP/FormHealthSummary.php b/src/CP/FormHealthSummary.php
new file mode 100644
index 0000000..c71253c
--- /dev/null
+++ b/src/CP/FormHealthSummary.php
@@ -0,0 +1,54 @@
+
+ */
+ public function allByFormHandle(): Collection
+ {
+ $since = Carbon::now()->subHours(24);
+
+ return ActivityLogEntry::all()
+ ->groupBy('form_handle')
+ ->mapWithKeys(function (Collection $rowsForForm, string|int $handle) use ($since): array {
+ $projection = new stdClass;
+ $projection->form_handle = (string) $handle;
+
+ /** @var ActivityLogEntry|null $latest */
+ $latest = $rowsForForm->sortByDesc(fn (ActivityLogEntry $row): int => $row->created_at->getTimestamp())->first();
+ // Normalize to ISO-8601 UTC so the front-end's Date() parses an unambiguous instant.
+ $projection->last_sync = $latest === null
+ ? null
+ : $latest->created_at->copy()->utc()->toIso8601String();
+
+ $projection->failure_count_24h = $rowsForForm
+ ->filter(static fn (ActivityLogEntry $row): bool => in_array(
+ $row->status,
+ ['permanent_failure', 'transient_failure'],
+ true,
+ ))
+ ->filter(static fn (ActivityLogEntry $row): bool => $row->created_at->greaterThanOrEqualTo($since))
+ ->count();
+
+ return [(string) $handle => $projection];
+ });
+ }
+}
diff --git a/src/CP/RetryController.php b/src/CP/RetryController.php
new file mode 100644
index 0000000..29c06c9
--- /dev/null
+++ b/src/CP/RetryController.php
@@ -0,0 +1,69 @@
+handle();
+
+ // Scoped find: an attempt UUID from another form cannot resolve here.
+ $row = ActivityLogEntry::find($log, $formHandle);
+
+ if ($row === null) {
+ throw new NotFoundHttpException("Activity log entry not found: {$log}");
+ }
+
+ // Honour `enabled` flag as kill-switch — never retry when the operator has
+ // toggled the integration off, even for historical failure rows.
+ $driver = app(StorageDriverInterface::class);
+ $config = $driver->getFormConfig($formHandle);
+ if (($config['enabled'] ?? false) !== true) {
+ return response()->json([
+ 'success' => false,
+ 'message' => __('MailerLite sync is disabled for this form.'),
+ ], 422);
+ }
+
+ // Pass the stored post-mapped payload as submissionData — the job detects
+ // the shape and bypasses mapFields() so we do not double-map on retry.
+ // Strict empty-string check (not ??) — an empty-string id must fall through.
+ $originalSubmissionId = $row->submission_id;
+ $submissionId = $originalSubmissionId !== null && $originalSubmissionId !== ''
+ ? $originalSubmissionId
+ : 'retry-'.$row->id;
+
+ SyncSubscriberJob::dispatch(
+ submissionId: $submissionId,
+ formHandle: $row->form_handle,
+ submissionData: $row->payload,
+ );
+
+ return response()->json([
+ 'success' => true,
+ 'message' => __('Retry dispatched.'),
+ ]);
+ }
+}
diff --git a/src/Fieldtypes/MailerLiteFieldFieldtype.php b/src/Fieldtypes/MailerLiteFieldFieldtype.php
new file mode 100644
index 0000000..f459174
--- /dev/null
+++ b/src/Fieldtypes/MailerLiteFieldFieldtype.php
@@ -0,0 +1,104 @@
+>
+ */
+ private static array $cache = [];
+
+ public function getIndexItems($request): Collection
+ {
+ return collect($this->fetchFields())->values();
+ }
+
+ /**
+ * @return array{id: string, title: string}
+ */
+ protected function toItemArray($id): array
+ {
+ if ($id === 'email') {
+ return ['id' => 'email', 'title' => 'Email (built-in)'];
+ }
+
+ foreach ($this->fetchFields() as $item) {
+ if ($item['id'] === (string) $id) {
+ return $item;
+ }
+ }
+
+ return ['id' => (string) $id, 'title' => (string) $id];
+ }
+
+ /**
+ * @return array
+ */
+ private function fetchFields(): array
+ {
+ $apiKey = app(StorageDriverInterface::class)->getApiKey();
+
+ if ($apiKey === '') {
+ // Empty key is configuration state, not error — no warning logged (D-17).
+ return [['id' => 'email', 'title' => 'Email (built-in)']];
+ }
+
+ $cacheKey = hash('sha256', $apiKey);
+ if (isset(self::$cache[$cacheKey])) {
+ return self::$cache[$cacheKey];
+ }
+
+ try {
+ $items = app(MailerLiteServiceInterface::class)->listFields($apiKey);
+ $result = array_merge(
+ [['id' => 'email', 'title' => 'Email (built-in)']],
+ $items,
+ );
+
+ return self::$cache[$cacheKey] = $result;
+ } catch (MailerLiteValidationException|MailerLiteHttpException $e) {
+ Log::warning('MailerLite fieldtype fetch failed', [
+ 'fieldtype' => self::class,
+ 'message' => $e->getMessage(),
+ ]);
+
+ // FT-05 — email baseline persists even on API error.
+ return [['id' => 'email', 'title' => 'Email (built-in)']];
+ }
+ }
+}
diff --git a/src/Fieldtypes/MailerLiteGroupFieldtype.php b/src/Fieldtypes/MailerLiteGroupFieldtype.php
new file mode 100644
index 0000000..bd352d7
--- /dev/null
+++ b/src/Fieldtypes/MailerLiteGroupFieldtype.php
@@ -0,0 +1,96 @@
+> */
+ private static array $cache = [];
+
+ public function getIndexItems($request): Collection
+ {
+ return collect($this->fetchGroups())->values();
+ }
+
+ /**
+ * @return array{id: string, title: string}
+ */
+ protected function toItemArray($id): array
+ {
+ foreach ($this->fetchGroups() as $item) {
+ if ($item['id'] === (string) $id) {
+ return $item;
+ }
+ }
+
+ return ['id' => (string) $id, 'title' => (string) $id];
+ }
+
+ /**
+ * @return array
+ */
+ private function fetchGroups(): array
+ {
+ $apiKey = app(StorageDriverInterface::class)->getApiKey();
+
+ if ($apiKey === '') {
+ return [];
+ }
+
+ $cacheKey = hash('sha256', $apiKey);
+ if (isset(self::$cache[$cacheKey])) {
+ return self::$cache[$cacheKey];
+ }
+
+ try {
+ $rawItems = app(MailerLiteServiceInterface::class)->listGroups($apiKey);
+
+ // SDK returns {id, name}; Statamic Relationship convention is {id, title}.
+ $items = [];
+ foreach ($rawItems as $row) {
+ $items[] = ['id' => $row['id'], 'title' => $row['name']];
+ }
+
+ return self::$cache[$cacheKey] = $items;
+ } catch (MailerLiteValidationException|MailerLiteHttpException $e) {
+ Log::warning('MailerLite fieldtype fetch failed', [
+ 'fieldtype' => self::class,
+ 'message' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+}
diff --git a/src/Fieldtypes/MailerLiteGroups.php b/src/Fieldtypes/MailerLiteGroups.php
deleted file mode 100644
index 78414c5..0000000
--- a/src/Fieldtypes/MailerLiteGroups.php
+++ /dev/null
@@ -1,91 +0,0 @@
-mailerLite();
-
- if (! $mailerLite) {
- return $this->invalidItemArray($id);
- }
-
- $response = $mailerLite->groups->find($id);
-
- if (! isset($response['body']['data'])) {
- return $this->invalidItemArray($id);
- }
-
- $group = $response['body']['data'];
-
- return [
- 'id' => $group['id'],
- 'title' => $group['name'],
- ];
- }
-
- public function getIndexItems($request)
- {
- $mailerLite = $this->mailerLite();
-
- if (! $mailerLite) {
- return collect();
- }
-
- $response = $mailerLite->groups->get([
- 'limit' => 100,
- 'sort' => 'name',
- ]);
-
- if (! isset($response['body']['data'])) {
- return collect();
- }
-
- return collect($response['body']['data'])->map(fn (array $group) => [
- 'id' => $group['id'],
- 'title' => $group['name'],
- ]);
- }
-
- protected function getColumns()
- {
- return [
- Column::make('title')->label(__('Name')),
- ];
- }
-
- public function preProcessIndex($data)
- {
- if (! $data) {
- return [];
- }
-
- return collect($data)->map(function ($id) {
- $item = $this->toItemArray($id);
-
- return $item['title'] ?? $id;
- })->join(', ');
- }
-
- private function mailerLite(): ?MailerLite
- {
- $apiKey = Addon::get('concept7/statamic-mailerlite')->setting('api_key');
-
- if (! $apiKey) {
- return null;
- }
-
- return new MailerLite(['api_key' => $apiKey]);
- }
-}
diff --git a/src/Http/Controllers/CP/FormConfigController.php b/src/Http/Controllers/CP/FormConfigController.php
deleted file mode 100644
index 776a3fe..0000000
--- a/src/Http/Controllers/CP/FormConfigController.php
+++ /dev/null
@@ -1,256 +0,0 @@
-label(__('Form')),
- Column::make('enabled')->label(__('Enabled')),
- Column::make('subscriber_groups')->label(__('Subscriber Groups')),
- ];
-
- $configs = $this->repository->all()->map(function ($config) {
- $form = Form::find($config->form());
-
- return [
- 'id' => $config->id(),
- 'title' => $form->title(),
- 'enabled' => $config->enabled(),
- 'subscriber_groups' => count($config->subscriberGroups()) ?: '—',
- 'edit_url' => cp_route('statamic-mailerlite.form-config.edit', $config->id()),
- 'delete_url' => cp_route('statamic-mailerlite.form-config.destroy', $config->id()),
- ];
- })->values();
-
- return Inertia::render('statamic-mailerlite::FormConfig/Index', [
- 'configs' => $configs,
- 'initialColumns' => $columns,
- 'title' => __('MailerLite'),
- 'createUrl' => cp_route('statamic-mailerlite.form-config.create'),
- ]);
- }
-
- public function create()
- {
- return PublishForm::make($this->formBlueprint())
- ->title(__('Create Form Config'))
- ->values([
- 'form_handle' => '',
- 'enabled' => false,
- 'subscriber_groups' => [],
- 'field_mapping' => [],
- ])
- ->asConfig()
- ->submittingTo(cp_route('statamic-mailerlite.form-config.store'), 'POST');
- }
-
- public function store(Request $request)
- {
- $publishForm = PublishForm::make($this->formBlueprint());
- $values = $publishForm->submit($request->all());
-
- $formHandle = $values['form_handle'];
-
- $this->findFormOrFail($formHandle);
-
- if ($this->repository->findByForm($formHandle)) {
- abort(422, __('A config for this form already exists.'));
- }
-
- $id = Str::uuid()->toString();
-
- $config = $this->repository->make();
- $config
- ->id($id)
- ->form($formHandle)
- ->enabled($values['enabled'])
- ->subscriberGroups($values['subscriber_groups'] ?: null)
- ->fieldMapping($values['field_mapping'] ?? []);
-
- $this->repository->save($config);
-
- session()->flash('success', __('Form config created'));
-
- return ['redirect' => cp_route('statamic-mailerlite.form-config.edit', $id)];
- }
-
- public function edit(string $id)
- {
- $config = $this->repository->find($id);
-
- if (! $config) {
- abort(404, __('Form config not found.'));
- }
-
- $form = $this->findFormOrFail($config->form());
-
- $values = [
- 'enabled' => $config->enabled(),
- 'subscriber_groups' => $config->subscriberGroups(),
- 'field_mapping' => $config->fieldMapping(),
- ];
-
- return PublishForm::make($this->formBlueprint($config->form()))
- ->title(__('MailerLite — :form', ['form' => $form->title()]))
- ->values($values)
- ->asConfig()
- ->submittingTo(cp_route('statamic-mailerlite.form-config.update', $id));
- }
-
- public function update(string $id, Request $request)
- {
- $config = $this->repository->find($id);
-
- if (! $config) {
- abort(404, __('Form config not found.'));
- }
-
- $this->findFormOrFail($config->form());
-
- $publishForm = PublishForm::make($this->formBlueprint($config->form()));
- $values = $publishForm->submit($request->all());
-
- $config
- ->enabled($values['enabled'])
- ->subscriberGroups($values['subscriber_groups'] ?: null)
- ->fieldMapping($values['field_mapping'] ?? []);
-
- $this->repository->save($config);
-
- $this->success(__('Saved'));
- }
-
- public function destroy(string $id)
- {
- $config = $this->repository->find($id);
-
- if (! $config) {
- abort(404, __('Form config not found.'));
- }
-
- $this->repository->delete($config);
-
- return [
- 'message' => __('Form config deleted'),
- 'redirect' => cp_route('statamic-mailerlite.form-config.index'),
- ];
- }
-
- private function findFormOrFail(string $handle): \Statamic\Contracts\Forms\Form
- {
- return Form::findOrFail($handle);
- }
-
- private function formBlueprint(?string $formHandle = null): \Statamic\Fields\Blueprint
- {
- $sections = [];
-
- if (! $formHandle) {
- $configuredForms = $this->repository->all()
- ->map(fn ($config) => $config->form())
- ->all();
-
- $availableForms = Form::all()
- ->reject(fn ($form) => in_array($form->handle(), $configuredForms))
- ->mapWithKeys(fn ($form) => [$form->handle() => __($form->title())])
- ->all();
-
- $sections[] = [
- 'display' => __('Form'),
- 'fields' => [
- [
- 'handle' => 'form_handle',
- 'field' => [
- 'type' => 'select',
- 'display' => __('Statamic Form'),
- 'instructions' => __('Select the form to configure for MailerLite.'),
- 'options' => $availableForms,
- 'validate' => 'required',
- ],
- ],
- ],
- ];
- }
-
- $sections[] = [
- 'display' => __('General'),
- 'fields' => [
- [
- 'handle' => 'enabled',
- 'field' => [
- 'type' => 'toggle',
- 'display' => __('Enabled'),
- 'instructions' => __('Enable MailerLite integration for this form.'),
- ],
- ],
- [
- 'handle' => 'subscriber_groups',
- 'field' => [
- 'type' => 'mailer_lite_groups',
- 'display' => __('Subscriber Groups'),
- 'instructions' => __('Select the MailerLite subscriber groups to add subscribers to.'),
- ],
- ],
- ],
- ];
-
- $sections[] = [
- 'display' => __('Field Mapping'),
- 'fields' => [
- [
- 'handle' => 'field_mapping',
- 'field' => [
- 'type' => 'grid',
- 'display' => __('Field Mapping'),
- 'instructions' => __('Map form fields to MailerLite subscriber fields.'),
- 'add_row' => __('Add Mapping'),
- 'fields' => [
- [
- 'handle' => 'form_field',
- 'field' => [
- 'type' => 'text',
- 'display' => __('Form Field'),
- ],
- ],
- [
- 'handle' => 'mailerlite_field',
- 'field' => [
- 'type' => 'text',
- 'display' => __('MailerLite Field'),
- ],
- ],
- ],
- ],
- ],
- ],
- ];
-
- return Blueprint::make()->setContents([
- 'tabs' => [
- 'main' => [
- 'sections' => $sections,
- ],
- ],
- ]);
- }
-}
diff --git a/src/Jobs/CreateSubscriberJob.php b/src/Jobs/CreateSubscriberJob.php
deleted file mode 100644
index e9dbe38..0000000
--- a/src/Jobs/CreateSubscriberJob.php
+++ /dev/null
@@ -1,62 +0,0 @@
- $submissionData
- * @param array $fieldMapping
- * @param array $subscriberGroups
- */
- public function __construct(
- private array $submissionData,
- private array $fieldMapping,
- private array $subscriberGroups,
- private string $apiKey,
- ) {}
-
- public function handle(): void
- {
- $mailerLite = new MailerLite(['api_key' => $this->apiKey]);
-
- $email = null;
- $fields = [];
-
- foreach ($this->fieldMapping as $mapping) {
- $formField = $mapping['form_field'];
- $mailerLiteField = $mapping['mailerlite_field'];
- $value = $this->submissionData[$formField] ?? null;
-
- if ($mailerLiteField === 'email') {
- $email = $value;
- } else {
- $fields[$mailerLiteField] = $value;
- }
- }
-
- if (! $email) {
- return;
- }
-
- $data = ['email' => $email];
-
- if (! empty($fields)) {
- $data['fields'] = $fields;
- }
-
- if (! empty($this->subscriberGroups)) {
- $data['groups'] = $this->subscriberGroups;
- }
-
- $mailerLite->subscribers->create($data);
- }
-}
diff --git a/src/Listeners/FormSavedListener.php b/src/Listeners/FormSavedListener.php
new file mode 100644
index 0000000..f1d9205
--- /dev/null
+++ b/src/Listeners/FormSavedListener.php
@@ -0,0 +1,44 @@
+form->get('enabled');
+
+ // Strict comparison — only literal bool true persists.
+ if ($enabled !== true) {
+ return;
+ }
+
+ $mappings = $event->form->get('mappings');
+ $groups = $event->form->get('groups');
+
+ // Storage uses `fields`; the form section's handle is `mappings`. Rekey here.
+ $payload = [
+ 'enabled' => true,
+ 'fields' => is_array($mappings) ? array_values($mappings) : [],
+ 'groups' => is_array($groups) ? array_values($groups) : [],
+ ];
+
+ $this->driver->saveFormConfig($event->form->handle(), $payload);
+ }
+}
diff --git a/src/Listeners/FormSubmittedListener.php b/src/Listeners/FormSubmittedListener.php
new file mode 100644
index 0000000..c1c90bf
--- /dev/null
+++ b/src/Listeners/FormSubmittedListener.php
@@ -0,0 +1,22 @@
+submission->set('ip_address', request()->ip());
+ }
+}
diff --git a/src/Listeners/SubmissionCreatedListener.php b/src/Listeners/SubmissionCreatedListener.php
index 5e95321..dcf86cd 100644
--- a/src/Listeners/SubmissionCreatedListener.php
+++ b/src/Listeners/SubmissionCreatedListener.php
@@ -1,38 +1,42 @@
submission->form()->handle();
- $config = $this->repository->findByForm($formHandle);
+ $form = $event->submission->form();
+ $handle = $form->handle();
+ $config = $this->driver->getFormConfig($handle);
- if (! $config || ! $config->enabled()) {
+ // Truthy check (WR-06) — '1', 'yes', true all enable; falsy/missing disable.
+ if (! ($config['enabled'] ?? false)) {
return;
}
- $apiKey = Addon::get('concept7/statamic-mailerlite')->setting('api_key');
-
- if (! $apiKey) {
- return;
- }
+ // Pass in-memory data when the form does not store — the queued job can't load it from disk.
+ $submissionData = $form->store() ? null : $event->submission->data()->all();
- CreateSubscriberJob::dispatch(
- submissionData: $event->submission->data()->all(),
- fieldMapping: $config->fieldMapping(),
- subscriberGroups: $config->subscriberGroups(),
- apiKey: $apiKey,
+ SyncSubscriberJob::dispatch(
+ (string) $event->submission->id(),
+ $handle,
+ $submissionData,
);
}
}
diff --git a/src/MailerLite/Contracts/MailerLiteServiceInterface.php b/src/MailerLite/Contracts/MailerLiteServiceInterface.php
new file mode 100644
index 0000000..43a36b4
--- /dev/null
+++ b/src/MailerLite/Contracts/MailerLiteServiceInterface.php
@@ -0,0 +1,60 @@
+ $fields Filtered, mapped subscriber fields
+ * @param array $groupIds MailerLite group IDs
+ * @return array SDK 1.x default HttpLayer returns array{status_code, headers, body, response}
+ *
+ * @throws MailerLiteValidationException When MailerLite returns HTTP 4xx (permanent).
+ * @throws MailerLiteHttpException When MailerLite returns 5xx or is unreachable (transient).
+ */
+ public function upsertSubscriber(
+ string $apiKey,
+ string $email,
+ array $fields,
+ array $groupIds,
+ ?string $ipAddress,
+ ): array;
+
+ /**
+ * Verify an API key by hitting the cheapest read-only endpoint.
+ *
+ * MUST NOT delegate to upsertSubscriber — that endpoint is upsert and would
+ * create real subscribers on every call.
+ *
+ * @throws MailerLiteValidationException When the API key is invalid (HTTP 4xx).
+ * @throws MailerLiteHttpException When MailerLite is unreachable or returns 5xx.
+ */
+ public function ping(string $apiKey): bool;
+
+ /**
+ * List MailerLite custom subscriber fields, mapped to {id, title} pairs.
+ *
+ * @return array
+ *
+ * @throws MailerLiteValidationException When the API key is invalid (HTTP 4xx).
+ * @throws MailerLiteHttpException When MailerLite is unreachable or returns 5xx.
+ */
+ public function listFields(string $apiKey): array;
+
+ /**
+ * List MailerLite groups, mapped to {id, name} pairs.
+ *
+ * @return array
+ *
+ * @throws MailerLiteValidationException When the API key is invalid (HTTP 4xx).
+ * @throws MailerLiteHttpException When MailerLite is unreachable or returns 5xx.
+ */
+ public function listGroups(string $apiKey): array;
+}
diff --git a/src/MailerLite/Exceptions/MailerLiteHttpException.php b/src/MailerLite/Exceptions/MailerLiteHttpException.php
new file mode 100644
index 0000000..0381036
--- /dev/null
+++ b/src/MailerLite/Exceptions/MailerLiteHttpException.php
@@ -0,0 +1,9 @@
+clientFactory = $clientFactory;
+ }
+
+ /**
+ * Map an SDK HTTP exception to the addon-namespace equivalent by status code.
+ *
+ * 4xx → permanent (validation/auth — no retry); 5xx → transient (Laravel retries).
+ * 401 specifically lands on the permanent branch — MailerLite returns 401 (not
+ * 422) for invalid bearer tokens, so the job classifies it as a configuration
+ * error rather than churning through the backoff schedule.
+ */
+ private function translateHttpException(SdkHttpException $e): MailerLiteValidationException|MailerLiteHttpException
+ {
+ $code = $e->getCode();
+
+ if ($code >= 400 && $code < 500) {
+ return new MailerLiteValidationException($e->getMessage(), $code, $e);
+ }
+
+ return new MailerLiteHttpException($e->getMessage(), $code, $e);
+ }
+
+ /**
+ * Upsert a subscriber via MailerLite's create() endpoint (create-is-upsert-by-email).
+ *
+ * Stateless — a fresh SDK client is constructed per call so no instance state
+ * survives between requests/jobs (multi-tenant safety even in single-mode v1.0).
+ *
+ * @param array $fields
+ * @param array $groupIds
+ * @return array SDK 1.x default HttpLayer returns array{status_code, headers, body, response}
+ *
+ * @throws MailerLiteValidationException When MailerLite returns HTTP 4xx (permanent).
+ * @throws MailerLiteHttpException When MailerLite returns HTTP 5xx or is unreachable (transient).
+ */
+ public function upsertSubscriber(
+ string $apiKey,
+ string $email,
+ array $fields,
+ array $groupIds,
+ ?string $ipAddress,
+ ): array {
+ $client = $this->clientFactory !== null
+ ? ($this->clientFactory)($apiKey)
+ : new MailerLiteClient(['api_key' => $apiKey]);
+
+ $payload = ['email' => $email];
+
+ if ($fields !== []) {
+ $payload['fields'] = $fields;
+ }
+ if ($groupIds !== []) {
+ $payload['groups'] = $groupIds;
+ }
+ if ($ipAddress !== null) {
+ $payload['ip_address'] = $ipAddress;
+ }
+
+ try {
+ return $client->subscribers->create($payload);
+ } catch (SdkValidationException $e) {
+ throw new MailerLiteValidationException($e->getMessage(), 422, $e);
+ } catch (SdkHttpException $e) {
+ throw $this->translateHttpException($e);
+ }
+ }
+
+ /**
+ * Verify an API key via MailerLite's cheapest read-only endpoint (`fields->get`).
+ *
+ * MUST NOT call subscribers->create — that endpoint is upsert and would create
+ * real subscribers on every call.
+ *
+ * @throws MailerLiteValidationException When MailerLite returns HTTP 4xx.
+ * @throws MailerLiteHttpException When MailerLite returns 5xx or is unreachable.
+ */
+ public function ping(string $apiKey): bool
+ {
+ $client = $this->clientFactory !== null
+ ? ($this->clientFactory)($apiKey)
+ : new MailerLiteClient(['api_key' => $apiKey]);
+
+ try {
+ $client->fields->get([]);
+
+ return true;
+ } catch (SdkValidationException $e) {
+ throw new MailerLiteValidationException($e->getMessage(), 422, $e);
+ } catch (SdkHttpException $e) {
+ throw $this->translateHttpException($e);
+ }
+ }
+
+ /**
+ * List MailerLite custom subscriber fields, mapped to {id, title} pairs.
+ *
+ * MailerLite-faithful — the synthetic `email` baseline is added by the fieldtype, not here.
+ *
+ * @return array
+ *
+ * @throws MailerLiteValidationException When MailerLite returns HTTP 4xx.
+ * @throws MailerLiteHttpException When MailerLite returns 5xx or is unreachable.
+ */
+ public function listFields(string $apiKey): array
+ {
+ $client = $this->clientFactory !== null
+ ? ($this->clientFactory)($apiKey)
+ : new MailerLiteClient(['api_key' => $apiKey]);
+
+ try {
+ $response = $client->fields->get([]);
+ } catch (SdkValidationException $e) {
+ throw new MailerLiteValidationException($e->getMessage(), 422, $e);
+ } catch (SdkHttpException $e) {
+ throw $this->translateHttpException($e);
+ }
+
+ $items = [];
+ foreach ($response['body']['data'] ?? [] as $field) {
+ if (! is_array($field) || ! isset($field['key']) || ! is_string($field['key'])) {
+ continue;
+ }
+ $title = (isset($field['name']) && is_string($field['name']))
+ ? $field['name']
+ : $field['key'];
+ $items[] = ['id' => $field['key'], 'title' => $title];
+ }
+
+ return $items;
+ }
+
+ /**
+ * List MailerLite groups, mapped to {id, name} pairs.
+ *
+ * @return array
+ *
+ * @throws MailerLiteValidationException When MailerLite returns HTTP 4xx.
+ * @throws MailerLiteHttpException When MailerLite returns 5xx or is unreachable.
+ */
+ public function listGroups(string $apiKey): array
+ {
+ $client = $this->clientFactory !== null
+ ? ($this->clientFactory)($apiKey)
+ : new MailerLiteClient(['api_key' => $apiKey]);
+
+ try {
+ $response = $client->groups->get([]);
+ } catch (SdkValidationException $e) {
+ throw new MailerLiteValidationException($e->getMessage(), 422, $e);
+ } catch (SdkHttpException $e) {
+ throw $this->translateHttpException($e);
+ }
+
+ $items = [];
+ foreach ($response['body']['data'] ?? [] as $group) {
+ if (! is_array($group)) {
+ continue;
+ }
+ if (! isset($group['id']) || ! is_string($group['id'])) {
+ continue;
+ }
+ if (! isset($group['name']) || ! is_string($group['name'])) {
+ continue;
+ }
+ $items[] = ['id' => $group['id'], 'name' => $group['name']];
+ }
+
+ return $items;
+ }
+}
diff --git a/src/Stache/FormConfigEntry.php b/src/Stache/FormConfigEntry.php
deleted file mode 100644
index 655e588..0000000
--- a/src/Stache/FormConfigEntry.php
+++ /dev/null
@@ -1,81 +0,0 @@
-fluentlyGetOrSet('slug')->args(func_get_args());
- }
-
- public function form(?string $form = null)
- {
- if (func_num_args() === 0) {
- return $this->get('form');
- }
-
- return $this->set('form', $form);
- }
-
- public function path(): string
- {
- return $this->initialPath ?? $this->buildPath();
- }
-
- public function buildPath(): string
- {
- return base_path("content/mailerlite/{$this->id()}.yaml");
- }
-
- /** @return array{form: string, enabled: bool, subscriber_groups: array|null, field_mapping: array} */
- public function fileData(): array
- {
- return Arr::removeNullValues([
- 'form' => $this->form(),
- 'enabled' => $this->enabled() ?: null,
- 'subscriber_groups' => $this->subscriberGroups() ?: null,
- 'field_mapping' => $this->fieldMapping() ?: null,
- ]);
- }
-
- public function enabled(?bool $enabled = null)
- {
- if (func_num_args() === 0) {
- return (bool) $this->get('enabled', false);
- }
-
- return $this->set('enabled', $enabled);
- }
-
- /** @param array|null $subscriberGroups */
- public function subscriberGroups(?array $subscriberGroups = null)
- {
- if (func_num_args() === 0) {
- return $this->get('subscriber_groups', []);
- }
-
- return $this->set('subscriber_groups', $subscriberGroups);
- }
-
- /**
- * @param array|null $fieldMapping
- * @return array|self
- */
- public function fieldMapping(?array $fieldMapping = null)
- {
- if (func_num_args() === 0) {
- return $this->get('field_mapping', []);
- }
-
- return $this->set('field_mapping', $fieldMapping);
- }
-
- public function fileExtension(): string
- {
- return 'yaml';
- }
-}
diff --git a/src/Stache/FormConfigRepository.php b/src/Stache/FormConfigRepository.php
deleted file mode 100644
index 24372ae..0000000
--- a/src/Stache/FormConfigRepository.php
+++ /dev/null
@@ -1,58 +0,0 @@
-stache = $stache;
- $this->store = $stache->store('mailerlite-form-configs');
- }
-
- public function all(): EntryCollection
- {
- return EntryCollection::make(
- $this->store->getItems($this->store->paths()->keys()->all())
- );
- }
-
- /** @param string $id */
- public function find($id): ?FormConfigEntry
- {
- return $this->store->getItem($id);
- }
-
- public function findByForm(string $formHandle): ?FormConfigEntry
- {
- return $this->all()->first(fn (FormConfigEntry $config) => $config->form() === $formHandle);
- }
-
- public function make(): FormConfigEntry
- {
- return new FormConfigEntry;
- }
-
- public function save($entry): void
- {
- $this->store->save($entry);
- }
-
- public function delete($entry): void
- {
- $this->store->delete($entry);
- }
-
- /** @return array */
- public static function bindings(): array
- {
- return [
- Entry::class => FormConfigEntry::class,
- ];
- }
-}
diff --git a/src/Stache/FormConfigStore.php b/src/Stache/FormConfigStore.php
deleted file mode 100644
index faab5c7..0000000
--- a/src/Stache/FormConfigStore.php
+++ /dev/null
@@ -1,59 +0,0 @@
-getPathName()), $this->directory);
-
- return substr_count($filename, '/') === 0 && $file->getExtension() === 'yaml';
- }
-
- public function makeItemFromFile($path, $contents)
- {
- $relative = Str::after($path, $this->directory);
- $id = Str::before($relative, '.yaml');
-
- $data = YAML::file($path)->parse($contents);
-
- $entry = (new FormConfigEntry)
- ->id($id)
- ->form($data['form'] ?? null)
- ->enabled($data['enabled'] ?? false)
- ->fieldMapping($data['field_mapping'] ?? [])
- ->initialPath($path);
-
- if (isset($data['subscriber_groups'])) {
- $entry->subscriberGroups($data['subscriber_groups']);
- }
-
- return $entry;
- }
-
- public function getItemKey($item): string
- {
- return $item->id();
- }
-
- protected function getKeyFromPath($path)
- {
- if ($key = parent::getKeyFromPath($path)) {
- return $key;
- }
-
- return pathinfo($path, PATHINFO_FILENAME);
- }
-}
diff --git a/src/StatamicMailerLiteServiceProvider.php b/src/StatamicMailerLiteServiceProvider.php
new file mode 100644
index 0000000..d62cb9b
--- /dev/null
+++ b/src/StatamicMailerLiteServiceProvider.php
@@ -0,0 +1,177 @@
+ ['resources/js/cp/index.js'],
+ 'publicDirectory' => 'public',
+ ];
+
+ /**
+ * Register storage-driver and stateless service bindings.
+ *
+ * StorageDriverInterface is scoped(), not singleton() — a fresh instance is
+ * produced per request/job context so tenant context or settings rotation
+ * cannot leak across boundaries.
+ */
+ public function register(): void
+ {
+ parent::register();
+
+ $this->app->scoped(StorageDriverInterface::class, AddonYamlDriver::class);
+
+ // Interface->concrete binding lets the Job type-hint the interface and tests
+ // mock it (final classes can't be Mockery-mocked).
+ $this->app->singleton(MailerLiteServiceInterface::class, MailerLiteService::class);
+ $this->app->singleton(ActivityLogger::class);
+ }
+
+ /**
+ * Boot wiring — permissions, nav, form section, rate limiters.
+ *
+ * Listeners auto-discovered from src/Listeners/ by parent; adding explicit
+ * Event::listen calls would double-fire each listener. The activity log
+ * persists onto each Statamic Submission YAML — no separate store, no
+ * migrations.
+ */
+ public function boot(): void
+ {
+ parent::boot();
+
+ Statamic::booted(function (): void {
+ $this->registerCpPermission();
+ $this->registerCpNav();
+ $this->registerFormSection();
+ });
+
+ $this->registerRetryRateLimiter();
+ }
+
+ /**
+ * Register the `configure mailerlite` CP permission. Super-user bypass is automatic.
+ */
+ private function registerCpPermission(): void
+ {
+ Permission::group('mailerlite', __('MailerLite'), function (): void {
+ Permission::register('configure mailerlite', function ($permission): void {
+ $permission
+ ->label(__('Configure MailerLite'))
+ ->description(__('Edit the MailerLite API key and per-form configuration.'));
+ });
+ });
+ }
+
+ /**
+ * Top-level CP nav: Concept7 lockup parent → MailerLite child.
+ *
+ * 'Top Level' is the canonical Statamic section key (not a translated label).
+ * `->can('configure mailerlite')` on both nodes hides the entry for unauthorized
+ * users — mitigates the leak of addon presence to users without permission.
+ */
+ private function registerCpNav(): void
+ {
+ $c7Icon = ' ';
+
+ Nav::extend(function ($nav) use ($c7Icon): void {
+ $nav->create(__('Concept7'))
+ ->section('Top Level')
+ ->icon($c7Icon)
+ ->can('configure mailerlite')
+ ->children([
+ $nav->item(__('MailerLite'))
+ ->route('mailerlite.dashboard')
+ ->icon('mail')
+ ->can('configure mailerlite'),
+ ]);
+ });
+ }
+
+ /**
+ * Register the per-form MailerLite section (enabled toggle + mappings replicator + groups).
+ */
+ private function registerFormSection(): void
+ {
+ Form::appendConfigFields('*', 'MailerLite', [
+ 'enabled' => [
+ 'type' => 'toggle',
+ 'display' => __('MailerLite sync'),
+ 'instructions' => __('When enabled, submissions to this form sync to MailerLite as subscribers.'),
+ 'default' => false,
+ ],
+ 'mappings' => [
+ 'type' => 'replicator',
+ 'display' => __('Field mapping'),
+ 'instructions' => __('Add a row to map a form field to a MailerLite subscriber field.'),
+ 'sets' => [
+ 'main' => [
+ 'sets' => [
+ 'mapping' => [
+ 'display' => __('Mapping'),
+ 'fields' => [
+ [
+ 'handle' => 'form_field',
+ 'field' => [
+ 'type' => 'text',
+ 'display' => __('Form field'),
+ ],
+ ],
+ [
+ 'handle' => 'mailerlite_field',
+ 'field' => [
+ 'type' => 'mailerlite_field',
+ 'display' => __('MailerLite field'),
+ // max_items:1 makes Relationship::process() return a scalar string
+ // (not an array) — required by the job's mapFields() typed-shape check.
+ 'max_items' => 1,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'groups' => [
+ 'type' => 'mailerlite_group',
+ 'display' => __('Groups'),
+ 'instructions' => __('Subscribers will be added to every group selected here.'),
+ ],
+ ]);
+ }
+
+ /**
+ * Retry rate limiter — 5/min/user. Mitigates retry-stampede against MailerLite's quota
+ * when an operator rage-clicks Retry during an outage.
+ */
+ private function registerRetryRateLimiter(): void
+ {
+ RateLimiter::for('mailerlite-retry', function (Request $request): Limit {
+ $userId = $request->user()?->getAuthIdentifier();
+
+ return Limit::perMinute(5)->by((string) $userId);
+ });
+ }
+}
diff --git a/src/StatamicMailerliteServiceProvider.php b/src/StatamicMailerliteServiceProvider.php
deleted file mode 100644
index cad6573..0000000
--- a/src/StatamicMailerliteServiceProvider.php
+++ /dev/null
@@ -1,44 +0,0 @@
- [
- SubmissionCreatedListener::class,
- ],
- ];
-
- protected $vite = [
- 'input' => ['resources/js/addon.js'],
- 'publicDirectory' => 'resources/dist',
- ];
-
- public function register(): void
- {
- parent::register();
-
- $this->app->booted(function () {
- $this->app['stache']->registerStore(
- (new FormConfigStore)->directory(base_path('content/mailerlite'))
- );
- });
- }
-
- public function bootAddon(): void
- {
- Nav::extend(function () {
- Nav::create('MailerLite')
- ->section('Tools')
- ->route('statamic-mailerlite.form-config.index')
- ->icon('mail');
- });
- }
-}
diff --git a/src/Storage/AddonYamlDriver.php b/src/Storage/AddonYamlDriver.php
new file mode 100644
index 0000000..0abf56b
--- /dev/null
+++ b/src/Storage/AddonYamlDriver.php
@@ -0,0 +1,118 @@
+settings();
+
+ if ($settings === null) {
+ // Lenient read: missing manifest = no key configured yet.
+ return '';
+ }
+
+ // Read via raw() — Statamic\Addons\Settings::get() routes values through
+ // (string) Antlers::parse(...) which coerces all scalars to strings,
+ // losing the typed-shape contract on per-form configs.
+ return (string) ($settings->raw()['api_key'] ?? '');
+ }
+
+ public function saveApiKey(string $key): void
+ {
+ $settings = $this->settings();
+
+ if ($settings === null) {
+ throw new MailerLiteStorageException(
+ 'MailerLite addon manifest is not loaded; cannot persist API key.',
+ );
+ }
+
+ try {
+ $settings->set('api_key', $key)->save();
+ } catch (Throwable $e) {
+ throw new MailerLiteStorageException(
+ 'Failed to persist MailerLite API key: '.$e->getMessage(),
+ previous: $e,
+ );
+ }
+ }
+
+ public function getFormConfig(string $handle): array
+ {
+ $settings = $this->settings();
+
+ if ($settings === null) {
+ return [];
+ }
+
+ $forms = (array) ($settings->raw()['forms'] ?? []);
+
+ return (array) ($forms[$handle] ?? []);
+ }
+
+ public function saveFormConfig(string $handle, array $data): void
+ {
+ $settings = $this->settings();
+
+ if ($settings === null) {
+ throw new MailerLiteStorageException(
+ "MailerLite addon manifest is not loaded; cannot persist config for form [{$handle}].",
+ );
+ }
+
+ try {
+ $forms = (array) ($settings->raw()['forms'] ?? []);
+ $forms[$handle] = $data;
+ $settings->set('forms', $forms)->save();
+ } catch (Throwable $e) {
+ throw new MailerLiteStorageException(
+ "Failed to persist MailerLite config for form [{$handle}]: ".$e->getMessage(),
+ previous: $e,
+ );
+ }
+ }
+
+ public function allFormConfigs(): array
+ {
+ $settings = $this->settings();
+
+ if ($settings === null) {
+ return [];
+ }
+
+ return (array) ($settings->raw()['forms'] ?? []);
+ }
+
+ /**
+ * Resolve the Settings instance, or null if the addon manifest is not yet
+ * registered (CP boot-order edge — fall through to lenient-read / loud-write).
+ */
+ private function settings(): ?Settings
+ {
+ if ($this->settings !== null) {
+ return $this->settings;
+ }
+
+ $addon = Addon::get(self::ADDON_ID);
+
+ if ($addon === null) {
+ return null;
+ }
+
+ return $this->settings = $addon->settings();
+ }
+}
diff --git a/src/Storage/Exceptions/MailerLiteStorageException.php b/src/Storage/Exceptions/MailerLiteStorageException.php
new file mode 100644
index 0000000..3af7861
--- /dev/null
+++ b/src/Storage/Exceptions/MailerLiteStorageException.php
@@ -0,0 +1,9 @@
+,
+ * fields?: array
+ * }
+ */
+ public function getFormConfig(string $handle): array;
+
+ /**
+ * Persists the per-form config for $handle (loud-write; no driver-side validation).
+ *
+ * @param array{
+ * enabled: bool,
+ * groups: array,
+ * fields: array
+ * } $data
+ *
+ * @throws MailerLiteStorageException
+ */
+ public function saveFormConfig(string $handle, array $data): void;
+
+ /**
+ * Returns ALL per-form configs keyed by form handle, or [] on miss (lenient read).
+ *
+ * @return array,
+ * fields: array
+ * }>
+ */
+ public function allFormConfigs(): array;
+}
diff --git a/src/Sync/SyncSubscriberJob.php b/src/Sync/SyncSubscriberJob.php
new file mode 100644
index 0000000..63ebbee
--- /dev/null
+++ b/src/Sync/SyncSubscriberJob.php
@@ -0,0 +1,294 @@
+ */
+ public array $backoff = [60, 300, 900];
+
+ /**
+ * IDs only — never serialise the API key or mappings into the queue payload.
+ *
+ * $submissionData carries the in-memory data when the form has store:false (the
+ * submission won't be on disk); null otherwise (job loads from disk).
+ *
+ * @param array|null $submissionData
+ */
+ public function __construct(
+ public readonly string $submissionId,
+ public readonly string $formHandle,
+ public readonly ?array $submissionData = null,
+ ) {}
+
+ /**
+ * Orchestrate the sync pipeline.
+ *
+ * Dependencies are method-injected so the scoped() driver resolves fresh per
+ * job and the API key never enters the queue payload. The finally block is
+ * the sole call site for the activity logger — exactly one log per outcome.
+ */
+ public function handle(
+ StorageDriverInterface $driver,
+ MailerLiteServiceInterface $service,
+ ActivityLogger $logger,
+ ): void {
+ $config = $driver->getFormConfig($this->formHandle);
+ $apiKey = $driver->getApiKey();
+
+ $result = null;
+ $payload = [];
+ $errorMessage = null;
+
+ // loadSubmission() is inside try so a missing form/submission still hits finally.
+ try {
+ $submission = $this->loadSubmission();
+
+ if ($apiKey === '') {
+ $result = 'permanent_failure';
+ $errorMessage = 'API key not configured';
+ $exception = new MailerLiteValidationException('API key not configured', 422);
+ $this->fail($exception);
+
+ // Throw so direct callers (tests, command runners) see the failure.
+ throw $exception;
+ }
+
+ if ($this->submissionData !== null && $this->isPostMappedPayload($this->submissionData)) {
+ // Phase 4 CP-05 post-mapped retry path — skip mapFields() and use stored payload verbatim.
+ $emailRaw = $this->submissionData['email'] ?? null;
+ $email = is_string($emailRaw) ? $emailRaw : '';
+
+ if ($email === '') {
+ $result = 'permanent_failure';
+ $errorMessage = 'Stored payload has empty email — cannot retry';
+ $exception = new MailerLiteValidationException($errorMessage, 422);
+ $this->fail($exception);
+ throw $exception;
+ }
+
+ $fieldsRaw = $this->submissionData['fields'] ?? [];
+ /** @var array $filteredFields */
+ $filteredFields = is_array($fieldsRaw)
+ ? array_filter($fieldsRaw, fn ($v): bool => $v !== '')
+ : [];
+
+ $groupsRaw = $this->submissionData['groups'] ?? [];
+ /** @var list $groups */
+ $groups = is_array($groupsRaw)
+ ? array_values(array_filter($groupsRaw, 'is_string'))
+ : [];
+
+ $ipRaw = $this->submissionData['ip_address'] ?? null;
+ $ipAddress = is_string($ipRaw) ? $ipRaw : null;
+
+ $payload = [
+ 'email' => $email,
+ 'fields' => $filteredFields,
+ 'groups' => $groups,
+ 'ip_address' => $ipAddress,
+ ];
+
+ $service->upsertSubscriber($apiKey, $email, $filteredFields, $groups, $ipAddress);
+
+ $result = 'success';
+ } else {
+ $mappedFields = $this->mapFields($submission, $config);
+ $filteredFields = array_filter($mappedFields, fn ($v): bool => $v !== '');
+
+ // Resolve email field handle from config (defaults to `email`).
+ $emailFieldRaw = $config['email_field'] ?? 'email';
+ $emailField = is_string($emailFieldRaw) && $emailFieldRaw !== '' ? $emailFieldRaw : 'email';
+ $email = $submission->get($emailField) ?? '';
+
+ if (! is_string($email) || $email === '') {
+ $result = 'permanent_failure';
+ $errorMessage = "Email field '{$emailField}' missing or empty on submission";
+ $exception = new MailerLiteValidationException($errorMessage, 422);
+ $this->fail($exception);
+
+ // Throw so direct callers (tests, command runners) see the failure.
+ throw $exception;
+ }
+
+ // Phase 2 WR-05 + Phase 4 WR-06 — narrow groups once + filter by is_string on both paths.
+ $groupsRaw = $config['groups'] ?? [];
+ /** @var list $groups */
+ $groups = is_array($groupsRaw)
+ ? array_values(array_filter($groupsRaw, 'is_string'))
+ : [];
+ $ipAddressRaw = $submission->get('ip_address');
+ $ipAddress = is_string($ipAddressRaw) ? $ipAddressRaw : null;
+
+ $payload = [
+ 'email' => $email,
+ 'fields' => $filteredFields,
+ 'groups' => $groups,
+ 'ip_address' => $ipAddress,
+ ];
+
+ $service->upsertSubscriber(
+ $apiKey,
+ $email,
+ $filteredFields,
+ $groups,
+ $ipAddress,
+ );
+
+ $result = 'success';
+ }
+ } catch (MailerLiteValidationException $e) {
+ $result = 'permanent_failure';
+ $errorMessage = $e->getMessage();
+ $this->fail($e); // Skip retries — 4xx are permanent.
+ // Throw so direct callers see the failure.
+ throw $e;
+ } catch (MailerLiteHttpException $e) {
+ $result = 'transient_failure';
+ $errorMessage = $e->getMessage();
+ // Let Laravel retry per $backoff; finally still writes the log.
+ throw $e;
+ } finally {
+ // Sole log-write site — finally runs even when a catch arm throws.
+ // Wrap so activity-log write failures don't escape to the form submitter.
+ try {
+ $logger->log(
+ formHandle: $this->formHandle,
+ submissionId: $this->submissionId,
+ status: $result ?? 'transient_failure',
+ payload: $payload,
+ errorMessage: $errorMessage,
+ );
+ } catch (\Throwable $logException) {
+ Log::warning('MailerLite activity log write failed — submission sync outcome not persisted', [
+ 'form_handle' => $this->formHandle,
+ 'submission_id' => $this->submissionId,
+ 'sync_status' => $result ?? 'transient_failure',
+ 'log_error' => $logException->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Load the submission from in-memory data (store:false) or from disk (store:true).
+ *
+ * @throws MailerLiteValidationException When form/submission missing (permanent — no retry).
+ */
+ private function loadSubmission(): Submission
+ {
+ $form = Form::find($this->formHandle);
+
+ if ($form === null) {
+ throw new MailerLiteValidationException(
+ "Form not found: {$this->formHandle}",
+ 422,
+ );
+ }
+
+ if ($this->submissionData !== null) {
+ $submission = $form->makeSubmission();
+ $submission->data($this->submissionData);
+
+ return $submission;
+ }
+
+ $submission = $form->submission($this->submissionId);
+
+ if ($submission === null) {
+ throw new MailerLiteValidationException(
+ "Submission not found: {$this->submissionId}",
+ 422,
+ );
+ }
+
+ return $submission;
+ }
+
+ /**
+ * Translate form_field => mailerlite_field per the typed config shape.
+ *
+ * Defensive against malformed rows — a missing or non-string field handle
+ * raises a permanent validation failure rather than corrupting the upsert.
+ *
+ * @param array $config
+ * @return array
+ *
+ * @throws MailerLiteValidationException When a fields[] entry is malformed.
+ */
+ private function mapFields(Submission $submission, array $config): array
+ {
+ $mapped = [];
+ $fields = $config['fields'] ?? [];
+
+ if (! is_array($fields)) {
+ throw new MailerLiteValidationException(
+ 'Malformed form config — fields must be an array',
+ 422,
+ );
+ }
+
+ foreach ($fields as $i => $mapping) {
+ if (
+ ! is_array($mapping)
+ || ! isset($mapping['form_field'], $mapping['mailerlite_field'])
+ || ! is_string($mapping['form_field'])
+ || ! is_string($mapping['mailerlite_field'])
+ ) {
+ throw new MailerLiteValidationException(
+ "Malformed fields[{$i}] in form config — expected {form_field, mailerlite_field} string pair",
+ 422,
+ );
+ }
+
+ $value = $submission->get($mapping['form_field']) ?? '';
+ $mapped[$mapping['mailerlite_field']] = $value;
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * Detect a post-mapped payload (a previous sync's persisted payload, used by Phase 4 CP-05 retry).
+ *
+ * Three-clause discriminator: email key, fields key, fields value is an array.
+ * A raw submission with a stray 'fields' handle would have a scalar value (fails clause 3).
+ *
+ * @param array $payload
+ */
+ private function isPostMappedPayload(array $payload): bool
+ {
+ return array_key_exists('email', $payload)
+ && array_key_exists('fields', $payload)
+ && is_array($payload['fields']);
+ }
+}
diff --git a/tests/Activity/ActivityLoggerTest.php b/tests/Activity/ActivityLoggerTest.php
new file mode 100644
index 0000000..f857618
--- /dev/null
+++ b/tests/Activity/ActivityLoggerTest.php
@@ -0,0 +1,192 @@
+toBe($first);
+ expect($first)->toBeInstanceOf(ActivityLogger::class);
+});
+
+it('declares constructor taking private readonly PayloadRedactor', function (): void {
+ $reflection = new ReflectionClass(ActivityLogger::class);
+ $constructor = $reflection->getConstructor();
+
+ expect($constructor)->not->toBeNull();
+ $params = $constructor->getParameters();
+ expect($params)->toHaveCount(1);
+ expect($params[0]->getName())->toBe('redactor');
+ expect((string) $params[0]->getType())->toBe(PayloadRedactor::class);
+
+ $property = $reflection->getProperty('redactor');
+ expect($property->isReadOnly())->toBeTrue();
+ expect($property->isPrivate())->toBeTrue();
+});
+
+it('exposes log as the only public method with the locked five-parameter signature', function (): void {
+ $reflection = new ReflectionClass(ActivityLogger::class);
+
+ expect($reflection->isFinal())->toBeTrue();
+
+ $publicMethods = array_filter(
+ $reflection->getMethods(ReflectionMethod::IS_PUBLIC),
+ fn (ReflectionMethod $m): bool => ! $m->isConstructor(),
+ );
+ $names = array_map(fn (ReflectionMethod $m): string => $m->getName(), $publicMethods);
+ expect(array_values($names))->toBe(['log']);
+
+ $method = new ReflectionMethod(ActivityLogger::class, 'log');
+ $params = $method->getParameters();
+ expect($params)->toHaveCount(5);
+ expect($params[0]->getName())->toBe('formHandle');
+ expect((string) $params[0]->getType())->toBe('string');
+ expect($params[1]->getName())->toBe('submissionId');
+ expect((string) $params[1]->getType())->toBe('?string');
+ expect($params[2]->getName())->toBe('status');
+ expect((string) $params[2]->getType())->toBe('string');
+ expect($params[3]->getName())->toBe('payload');
+ expect((string) $params[3]->getType())->toBe('array');
+ expect($params[4]->getName())->toBe('errorMessage');
+ expect((string) $params[4]->getType())->toBe('?string');
+ expect((string) $method->getReturnType())->toBe('void');
+});
+
+it('persists a success attempt with all six columns populated and payload as array', function (): void {
+ $submission = new FakeSubmission(
+ id: 'sub-001',
+ form: Mockery::mock(FormContract::class),
+ );
+ $form = makeFakeForm('contact', [$submission]);
+ bindFormFacade(['contact' => $form]);
+
+ app(ActivityLogger::class)->log(
+ formHandle: 'contact',
+ submissionId: 'sub-001',
+ status: 'success',
+ payload: ['email' => 'jane@example.com', 'fields' => ['name' => 'Jane']],
+ errorMessage: null,
+ );
+
+ $sync = $submission->get('mailerlite_sync');
+ expect($sync)->toBeArray()->toHaveKey('attempts');
+ expect($sync['attempts'])->toHaveCount(1);
+ $attempt = $sync['attempts'][0];
+ expect($attempt['status'])->toBe('success');
+ expect($attempt['payload'])->toBe(['email' => 'jane@example.com', 'fields' => ['name' => 'Jane']]);
+ expect($attempt['error_message'])->toBeNull();
+ expect($attempt['id'])->toBeString()->not->toBeEmpty();
+ expect($attempt['attempted_at'])->toBeString();
+ expect($submission->saveWasCalled())->toBeTrue();
+});
+
+it('persists a transient_failure attempt with errorMessage', function (): void {
+ $submission = new FakeSubmission(id: 'sub-002', form: Mockery::mock(FormContract::class));
+ $form = makeFakeForm('newsletter', [$submission]);
+ bindFormFacade(['newsletter' => $form]);
+
+ app(ActivityLogger::class)->log(
+ formHandle: 'newsletter',
+ submissionId: 'sub-002',
+ status: 'transient_failure',
+ payload: ['email' => 'a@b.com'],
+ errorMessage: 'connection timeout',
+ );
+
+ $entries = ActivityLogEntry::all();
+ $row = $entries->firstWhere('status', 'transient_failure');
+ expect($row)->not->toBeNull();
+ expect($row->error_message)->toBe('connection timeout');
+ expect($row->status)->toBe('transient_failure');
+});
+
+it('skips when submissionId is null (warns, no annotation written)', function (): void {
+ bindFormFacade(['support' => makeFakeForm('support')]);
+
+ app(ActivityLogger::class)->log(
+ formHandle: 'support',
+ submissionId: null,
+ status: 'permanent_failure',
+ payload: [],
+ errorMessage: 'API key not configured',
+ );
+
+ // No submission, no annotation — the constraint introduced by D-05.
+ expect(ActivityLogEntry::all())->toHaveCount(0);
+});
+
+it('redacts the payload before persistence', function (): void {
+ $submission = new FakeSubmission(id: 'sub-redact', form: Mockery::mock(FormContract::class));
+ bindFormFacade(['signup' => makeFakeForm('signup', [$submission])]);
+
+ app(ActivityLogger::class)->log(
+ formHandle: 'signup',
+ submissionId: 'sub-redact',
+ status: 'success',
+ payload: [
+ 'email' => 'jane@example.com',
+ 'password' => 'super_secret_123',
+ 'token' => 'tk_xxx',
+ 'name' => 'Jane',
+ ],
+ errorMessage: null,
+ );
+
+ $stored = $submission->get('mailerlite_sync')['attempts'][0]['payload'];
+ expect($stored)->toBe(['email' => 'jane@example.com', 'name' => 'Jane']);
+ expect($stored)->not->toHaveKey('password');
+ expect($stored)->not->toHaveKey('token');
+});
+
+it('appends new attempts on each call (multi-attempt per submission — D-05 Option B)', function (): void {
+ $submission = new FakeSubmission(id: 'sub-1', form: Mockery::mock(FormContract::class));
+ bindFormFacade(['a' => makeFakeForm('a', [$submission])]);
+
+ $logger = app(ActivityLogger::class);
+ $logger->log('a', 'sub-1', 'transient_failure', [], 'temp');
+ $logger->log('a', 'sub-1', 'success', [], null);
+
+ $attempts = $submission->get('mailerlite_sync')['attempts'];
+ expect($attempts)->toHaveCount(2);
+ expect(array_column($attempts, 'status'))->toContain('transient_failure', 'success');
+});
+
+it('skips when form is unknown (warns, no exception)', function (): void {
+ bindFormFacade([]); // no forms registered
+
+ // Should not throw; logs a warning and returns.
+ expect(fn (): mixed => app(ActivityLogger::class)->log(
+ formHandle: 'ghost',
+ submissionId: 'sub-x',
+ status: 'success',
+ payload: [],
+ errorMessage: null,
+ ))->not->toThrow(Exception::class);
+});
+
+it('skips when submission cannot be loaded (form.store:false case — warns, no exception)', function (): void {
+ // Form exists but the submission id is not present in its submissions collection.
+ bindFormFacade(['live' => makeFakeForm('live', [])]);
+
+ expect(fn (): mixed => app(ActivityLogger::class)->log(
+ formHandle: 'live',
+ submissionId: 'in-memory-only',
+ status: 'success',
+ payload: [],
+ errorMessage: null,
+ ))->not->toThrow(Exception::class);
+
+ // No annotation possible — no submission on disk to attach to.
+ expect(ActivityLogEntry::all())->toHaveCount(0);
+});
diff --git a/tests/Activity/PayloadRedactorTest.php b/tests/Activity/PayloadRedactorTest.php
new file mode 100644
index 0000000..6e9901c
--- /dev/null
+++ b/tests/Activity/PayloadRedactorTest.php
@@ -0,0 +1,93 @@
+redact([]))->toBe([]);
+});
+
+it('passes safe keys through unchanged', function (): void {
+ $input = ['email' => 'jane@example.com', 'name' => 'Jane', 'ip_address' => '203.0.113.7'];
+ expect((new PayloadRedactor)->redact($input))->toBe($input);
+});
+
+it('strips the password key', function (): void {
+ $result = (new PayloadRedactor)->redact(['email' => 'a@b.com', 'password' => 'secret123']);
+ expect($result)->toBe(['email' => 'a@b.com']);
+ expect($result)->not->toHaveKey('password');
+});
+
+it('strips the token key', function (): void {
+ $result = (new PayloadRedactor)->redact(['email' => 'a@b.com', 'token' => 'tk_xyz']);
+ expect($result)->not->toHaveKey('token');
+});
+
+it('strips the secret key', function (): void {
+ $result = (new PayloadRedactor)->redact(['email' => 'a@b.com', 'secret' => 's_xyz']);
+ expect($result)->not->toHaveKey('secret');
+});
+
+it('strips the card key', function (): void {
+ $result = (new PayloadRedactor)->redact(['email' => 'a@b.com', 'card' => '4111-1111-1111-1111']);
+ expect($result)->not->toHaveKey('card');
+});
+
+it('strips the cvv key', function (): void {
+ $result = (new PayloadRedactor)->redact(['email' => 'a@b.com', 'cvv' => '123']);
+ expect($result)->not->toHaveKey('cvv');
+});
+
+it('matches case-insensitively', function (): void {
+ $result = (new PayloadRedactor)->redact([
+ 'Password' => 'a',
+ 'TOKEN' => 'b',
+ 'Secret' => 'c',
+ 'CARD_NUMBER' => 'd',
+ 'cvV2' => 'e',
+ 'email' => 'jane@example.com',
+ ]);
+
+ expect($result)->toBe(['email' => 'jane@example.com']);
+});
+
+it('matches substring keys (D-18 regex has no anchors)', function (): void {
+ $result = (new PayloadRedactor)->redact([
+ 'cardNumber' => 'a',
+ 'password_hash' => 'b',
+ 'api_token' => 'c',
+ 'customer_secret' => 'd',
+ 'cvv2' => 'e',
+ 'email' => 'jane@example.com',
+ ]);
+
+ expect($result)->toBe(['email' => 'jane@example.com']);
+});
+
+it('does not recurse into nested arrays (shallow per D-18)', function (): void {
+ $input = [
+ 'email' => 'jane@example.com',
+ 'user' => ['password' => 'leaked', 'name' => 'Jane'],
+ ];
+
+ // The top-level 'user' key is not secret-shaped, so it stays.
+ // The nested 'password' is NOT touched by Phase 2's shallow redactor.
+ $result = (new PayloadRedactor)->redact($input);
+
+ expect($result)->toBe($input); // unchanged at top level
+ expect($result['user']['password'])->toBe('leaked'); // nested untouched
+});
+
+it('passes integer keys (indexed array) through unchanged', function (): void {
+ $input = ['safe', 'values', 'here'];
+ expect((new PayloadRedactor)->redact($input))->toBe($input);
+});
+
+it('is stateless and deterministic', function (): void {
+ $redactor1 = new PayloadRedactor;
+ $redactor2 = new PayloadRedactor;
+
+ $input = ['email' => 'a@b.com', 'password' => 'secret'];
+ expect($redactor1->redact($input))->toBe($redactor2->redact($input));
+});
diff --git a/tests/ArchTest.php b/tests/ArchTest.php
deleted file mode 100644
index 87fb64c..0000000
--- a/tests/ArchTest.php
+++ /dev/null
@@ -1,5 +0,0 @@
-expect(['dd', 'dump', 'ray'])
- ->each->not->toBeUsed();
diff --git a/tests/CP/ActivityControllerTest.php b/tests/CP/ActivityControllerTest.php
new file mode 100644
index 0000000..79b3e14
--- /dev/null
+++ b/tests/CP/ActivityControllerTest.php
@@ -0,0 +1,262 @@
+email('admin+permitted@example.com')
+ ->set('name', 'Permitted Admin')
+ ->save();
+ Role::make()
+ ->handle('mailerlite_admin')
+ ->title('MailerLite Admin')
+ ->addPermission('access cp')
+ ->addPermission('configure mailerlite')
+ ->save();
+ $user->assignRole('mailerlite_admin')->save();
+
+ return $user;
+}
+
+function makeActivitySuperUser(): Statamic\Contracts\Auth\User
+{
+ return User::make()
+ ->email('super@example.com')
+ ->set('name', 'Super')
+ ->makeSuper()
+ ->save();
+}
+
+function makeActivityNonPermittedUser(): Statamic\Contracts\Auth\User
+{
+ $user = User::make()
+ ->email('regular@example.com')
+ ->set('name', 'Regular')
+ ->save();
+ Role::make()
+ ->handle('cp_only')
+ ->title('CP Only')
+ ->addPermission('access cp')
+ ->save();
+ $user->assignRole('cp_only')->save();
+
+ return $user;
+}
+
+/**
+ * Build a FakeSubmission whose `mailerlite_sync.attempts[]` contains exactly one
+ * attempt with the given attributes. Pass through `seedAttempt` to assemble forms
+ * with multiple submissions.
+ *
+ * @param array{
+ * submission_id?: string,
+ * status?: string,
+ * payload?: array,
+ * error_message?: string|null,
+ * } $attrs
+ */
+function seedActivityAttempt(array $attrs = [], ?Carbon $createdAt = null): FakeSubmission
+{
+ $submissionId = (string) ($attrs['submission_id'] ?? 'sub-'.Str::uuid()->toString());
+ $status = (string) ($attrs['status'] ?? 'success');
+ $payload = isset($attrs['payload']) && is_array($attrs['payload']) ? $attrs['payload'] : [];
+ $errorMessage = array_key_exists('error_message', $attrs)
+ ? (is_string($attrs['error_message']) ? $attrs['error_message'] : null)
+ : null;
+
+ $attempt = [
+ 'id' => (string) Str::uuid(),
+ 'attempted_at' => ($createdAt ?? Carbon::now())->toIso8601String(),
+ 'status' => $status,
+ 'payload' => $payload,
+ 'error_message' => $errorMessage,
+ ];
+
+ return new FakeSubmission(
+ id: $submissionId,
+ form: Mockery::mock(FormContract::class), // placeholder, replaced by makeFakeForm
+ data: ['mailerlite_sync' => ['attempts' => [$attempt]]],
+ );
+}
+
+/**
+ * Wire Form::find / Form::all for a single primary form plus optional secondary
+ * forms keyed by handle.
+ *
+ * @param array $submissions
+ * @param array}> $extras
+ */
+function bindActivityForm(string $handle, string $title, array $submissions = [], array $extras = []): void
+{
+ $primary = makeFakeForm($handle, $submissions, $title);
+ $forms = [$handle => $primary];
+ foreach ($extras as $extraHandle => $extraSpec) {
+ $forms[$extraHandle] = makeFakeForm(
+ $extraHandle,
+ $extraSpec['submissions'] ?? [],
+ $extraSpec['title'] ?? null,
+ );
+ }
+ bindFormFacade($forms);
+}
+
+it('show() returns Inertia component MailerLiteActivity for valid form handle', function (): void {
+ bindActivityForm('contact', 'Contact');
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('MailerLiteActivity')
+ ->has('form.handle')
+ ->has('form.title')
+ ->has('entries')
+ ->has('dashboardUrl'));
+});
+
+it('show() entries are ordered by attempted_at DESC (newest first)', function (): void {
+ $firstSub = seedActivityAttempt([
+ 'submission_id' => 'a',
+ 'status' => 'success',
+ 'payload' => ['email' => 'a@b.com'],
+ 'error_message' => null,
+ ], now()->subMinutes(10));
+
+ $secondSub = seedActivityAttempt([
+ 'submission_id' => 'b',
+ 'status' => 'permanent_failure',
+ 'payload' => ['email' => 'b@b.com'],
+ 'error_message' => 'x',
+ ], now()->subMinutes(2));
+
+ bindActivityForm('contact', 'Contact', [$firstSub, $secondSub]);
+
+ // Resolve the entries via the read DTO so we have stable ids to compare against.
+ $entries = ActivityLogEntry::forForm('contact')
+ ->sortByDesc(fn (ActivityLogEntry $e): int => $e->created_at->getTimestamp())
+ ->values();
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('entries.0.id', $entries[0]->id)
+ ->where('entries.1.id', $entries[1]->id));
+});
+
+it('show() entries[*] payload contains id, created_at, status, payload, error_message, retry_url, can_retry', function (): void {
+ $sub = seedActivityAttempt([
+ 'submission_id' => 'a',
+ 'status' => 'success',
+ 'payload' => ['email' => 'a@b.com'],
+ 'error_message' => null,
+ ]);
+
+ bindActivityForm('contact', 'Contact', [$sub]);
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('entries.0.id')
+ ->has('entries.0.created_at')
+ ->where('entries.0.status', 'success')
+ ->has('entries.0.payload')
+ ->has('entries.0.error_message')
+ ->has('entries.0.retry_url')
+ ->where('entries.0.can_retry', false));
+});
+
+it('show() can_retry is true for transient_failure and permanent_failure rows, false for success rows (D-07)', function (): void {
+ $successSub = seedActivityAttempt(['submission_id' => 's', 'status' => 'success']);
+ $transientSub = seedActivityAttempt(['submission_id' => 't', 'status' => 'transient_failure', 'error_message' => 'x']);
+ $permanentSub = seedActivityAttempt(['submission_id' => 'p', 'status' => 'permanent_failure', 'error_message' => 'x']);
+
+ bindActivityForm('contact', 'Contact', [$successSub, $transientSub, $permanentSub]);
+
+ $response = $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']));
+
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->has('entries', 3)
+ ->where('entries.0.can_retry', fn ($v) => is_bool($v))
+ ->where('entries.1.can_retry', fn ($v) => is_bool($v))
+ ->where('entries.2.can_retry', fn ($v) => is_bool($v)));
+
+ foreach (ActivityLogEntry::forForm('contact') as $entry) {
+ $expectedCanRetry = in_array($entry->status, ['transient_failure', 'permanent_failure'], true);
+ expect($expectedCanRetry)->toBe($entry->status !== 'success');
+ }
+});
+
+it('show() entries are scoped to the form_handle URL parameter — does NOT leak entries from other forms', function (): void {
+ $contactSub = seedActivityAttempt(['submission_id' => 's1', 'status' => 'success']);
+ $newsletterSub = seedActivityAttempt(['submission_id' => 's2', 'status' => 'success']);
+
+ bindActivityForm('contact', 'Contact', [$contactSub], [
+ 'newsletter' => ['title' => 'Newsletter', 'submissions' => [$newsletterSub]],
+ ]);
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1));
+});
+
+it('show() returns 200 for permitted user', function (): void {
+ bindActivityForm('contact', 'Contact');
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertOk();
+});
+
+it('show() redirects non-permitted user (CP redirect pattern)', function (): void {
+ bindActivityForm('contact', 'Contact');
+
+ $this->actingAs(makeActivityNonPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertRedirect();
+});
+
+it('show() returns 200 for super-user (bypass)', function (): void {
+ bindActivityForm('contact', 'Contact');
+
+ $this->actingAs(makeActivitySuperUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertOk();
+});
+
+it('show() empty rows produces an empty entries array', function (): void {
+ bindActivityForm('contact', 'Contact');
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'contact']))
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 0));
+});
+
+it('show() returns 404 when form_handle does not match any Statamic form', function (): void {
+ Form::shouldReceive('find')->andReturn(null);
+ Form::shouldReceive('all')->andReturn(collect());
+
+ $this->actingAs(makeActivityPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.activity.show', ['form' => 'nonexistent']))
+ ->assertNotFound();
+});
diff --git a/tests/CP/DashboardControllerTest.php b/tests/CP/DashboardControllerTest.php
new file mode 100644
index 0000000..90a8e33
--- /dev/null
+++ b/tests/CP/DashboardControllerTest.php
@@ -0,0 +1,292 @@
+email('admin+permitted@example.com')
+ ->set('name', 'Permitted Admin')
+ ->save();
+ Role::make()
+ ->handle('mailerlite_admin')
+ ->title('MailerLite Admin')
+ ->addPermission('access cp')
+ ->addPermission('configure mailerlite')
+ ->save();
+ $user->assignRole('mailerlite_admin')->save();
+
+ return $user;
+}
+
+function makeDashboardSuperUser(): Statamic\Contracts\Auth\User
+{
+ return User::make()
+ ->email('super@example.com')
+ ->set('name', 'Super')
+ ->makeSuper()
+ ->save();
+}
+
+function makeDashboardNonPermittedUser(): Statamic\Contracts\Auth\User
+{
+ $user = User::make()
+ ->email('regular@example.com')
+ ->set('name', 'Regular')
+ ->save();
+ Role::make()
+ ->handle('cp_only')
+ ->title('CP Only')
+ ->addPermission('access cp')
+ ->save();
+ $user->assignRole('cp_only')->save();
+
+ return $user;
+}
+
+/**
+ * Build a FakeSubmission carrying ONE `mailerlite_sync.attempts[]` entry.
+ *
+ * @param array{
+ * submission_id?: string,
+ * status?: string,
+ * payload?: array,
+ * error_message?: string|null,
+ * } $attrs
+ */
+function seedDashboardAttempt(array $attrs = [], ?Carbon $createdAt = null): FakeSubmission
+{
+ $submissionId = (string) ($attrs['submission_id'] ?? 'sub-'.Str::uuid()->toString());
+ $attempt = [
+ 'id' => (string) Str::uuid(),
+ 'attempted_at' => ($createdAt ?? Carbon::now())->toIso8601String(),
+ 'status' => (string) ($attrs['status'] ?? 'success'),
+ 'payload' => isset($attrs['payload']) && is_array($attrs['payload']) ? $attrs['payload'] : [],
+ 'error_message' => array_key_exists('error_message', $attrs)
+ ? (is_string($attrs['error_message']) ? $attrs['error_message'] : null)
+ : null,
+ ];
+
+ return new FakeSubmission(
+ id: $submissionId,
+ form: Mockery::mock(FormContract::class), // placeholder, replaced by makeDashboardForm
+ data: ['mailerlite_sync' => ['attempts' => [$attempt]]],
+ );
+}
+
+/**
+ * Build a form mock with the given submissions. `submissions()` returns them
+ * directly so ActivityLogEntry::forForm() and FormHealthSummary can iterate.
+ *
+ * @param array $submissions
+ */
+function makeDashboardForm(string $handle, string $title, array $submissions = []): FormContract
+{
+ $form = Mockery::mock(FormContract::class);
+ $form->shouldReceive('handle')->andReturn($handle);
+ $form->shouldReceive('title')->andReturn($title);
+ $form->shouldReceive('store')->andReturn(true);
+ $form->shouldReceive('submissions')->andReturn(new Collection($submissions));
+
+ // Back-ref so $submission->form()->handle() works inside ActivityLogEntry::forSubmission().
+ foreach ($submissions as $fake) {
+ $fake->form($form);
+ }
+
+ return $form;
+}
+
+/**
+ * @param array $forms
+ */
+function bindDashboardForms(array $forms): void
+{
+ Form::shouldReceive('all')->andReturn(new Collection($forms));
+ Form::shouldReceive('find')->andReturnUsing(function ($handle) use ($forms): ?FormContract {
+ foreach ($forms as $form) {
+ if ((string) $form->handle() === (string) $handle) {
+ return $form;
+ }
+ }
+
+ return null;
+ });
+}
+
+/**
+ * @param array, fields: array}> $allConfigs
+ */
+function bindDashboardDriver(array $allConfigs = []): MockInterface
+{
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('allFormConfigs')->andReturn($allConfigs);
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ return $driver;
+}
+
+it('index() returns Inertia component MailerLiteDashboard with forms array', function (): void {
+ bindDashboardForms([makeDashboardForm('contact', 'Contact')]);
+ bindDashboardDriver(['contact' => ['enabled' => true, 'groups' => ['g1'], 'fields' => []]]);
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('MailerLiteDashboard')
+ ->has('forms', 1));
+});
+
+it('index() forms[*] payload contains handle, title, enabled, groups, last_sync, failure_count_24h, activity_url, edit_url', function (): void {
+ bindDashboardForms([makeDashboardForm('contact', 'Contact')]);
+ bindDashboardDriver(['contact' => ['enabled' => true, 'groups' => ['g1', 'g2'], 'fields' => []]]);
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('forms.0.handle', 'contact')
+ ->where('forms.0.title', 'Contact')
+ ->where('forms.0.enabled', true)
+ ->where('forms.0.groups', ['g1', 'g2'])
+ ->has('forms.0.last_sync')
+ ->where('forms.0.failure_count_24h', 0)
+ ->has('forms.0.activity_url')
+ ->has('forms.0.edit_url'));
+});
+
+it('index() failure_count_24h aggregates permanent + transient failures within rolling 24h window (D-02 + D-03)', function (): void {
+ $submissions = [
+ seedDashboardAttempt(['submission_id' => 's1', 'status' => 'permanent_failure', 'error_message' => 'x']),
+ seedDashboardAttempt(['submission_id' => 's2', 'status' => 'transient_failure', 'error_message' => 'x']),
+ seedDashboardAttempt(['submission_id' => 's3', 'status' => 'success']),
+ ];
+ bindDashboardForms([makeDashboardForm('contact', 'Contact', $submissions)]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page->where('forms.0.failure_count_24h', 2));
+});
+
+it('index() failure_count_24h excludes success rows and rows older than 24h', function (): void {
+ $submissions = [
+ seedDashboardAttempt([
+ 'submission_id' => 's_old',
+ 'status' => 'permanent_failure',
+ 'error_message' => 'x',
+ ], Carbon::now()->subHours(25)),
+ seedDashboardAttempt([
+ 'submission_id' => 's_recent',
+ 'status' => 'transient_failure',
+ 'error_message' => 'x',
+ ]),
+ ];
+ bindDashboardForms([makeDashboardForm('contact', 'Contact', $submissions)]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page->where('forms.0.failure_count_24h', 1));
+});
+
+it('index() last_sync is the latest attempted_at for the form regardless of status', function (): void {
+ $submissions = [
+ seedDashboardAttempt(['submission_id' => 'a', 'status' => 'success'], Carbon::now()->subMinutes(10)),
+ seedDashboardAttempt(['submission_id' => 'b', 'status' => 'permanent_failure', 'error_message' => 'x'], Carbon::now()->subMinutes(2)),
+ ];
+ bindDashboardForms([makeDashboardForm('contact', 'Contact', $submissions)]);
+ bindDashboardDriver();
+
+ $response = $this->actingAs(makeDashboardPermittedUser(), 'web')->get(cp_route('mailerlite.dashboard'));
+ $response->assertInertia(fn (AssertableInertia $page) => $page->has('forms.0.last_sync'));
+});
+
+// BL-01 regression — the dashboard's last_sync field MUST carry a timezone marker
+// (Z or +00:00). FormHealthSummary normalizes to ISO-8601 UTC.
+it('index() forms.0.last_sync carries an ISO-8601 timezone marker (BL-01)', function (): void {
+ $submissions = [seedDashboardAttempt(['submission_id' => 's-tz', 'status' => 'success'])];
+ bindDashboardForms([makeDashboardForm('contact', 'Contact', $submissions)]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('forms.0.last_sync', fn ($value) => is_string($value)
+ && (str_ends_with($value, 'Z') || str_ends_with($value, '+00:00'))));
+});
+
+it('index() forms[*].edit_url ends with #mailerlite for D-15 fragment focus', function (): void {
+ bindDashboardForms([makeDashboardForm('contact', 'Contact')]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('forms.0.edit_url', fn ($url) => str_ends_with((string) $url, '#mailerlite')));
+});
+
+it('index() returns an empty forms array when no Statamic forms exist', function (): void {
+ bindDashboardForms([]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page->has('forms', 0));
+});
+
+it('GET /cp/mailerlite returns 200 for permitted user', function (): void {
+ bindDashboardForms([]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertOk();
+});
+
+it('GET /cp/mailerlite redirects non-permitted user (Statamic CP redirect pattern, not 403)', function (): void {
+ bindDashboardForms([]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardNonPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertRedirect();
+});
+
+it('GET /cp/mailerlite returns 200 for super-user (bypass)', function (): void {
+ bindDashboardForms([]);
+ bindDashboardDriver();
+
+ $this->actingAs(makeDashboardSuperUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertOk();
+});
+
+it('index() reads form configs via StorageDriverInterface, never via raw Form::all()->toArray()', function (): void {
+ bindDashboardForms([makeDashboardForm('contact', 'Contact')]);
+ $driver = bindDashboardDriver(['contact' => ['enabled' => true, 'groups' => [], 'fields' => []]]);
+
+ $this->actingAs(makeDashboardPermittedUser(), 'web')
+ ->get(cp_route('mailerlite.dashboard'))
+ ->assertOk();
+
+ $driver->shouldHaveReceived('allFormConfigs');
+});
diff --git a/tests/CP/NavTest.php b/tests/CP/NavTest.php
new file mode 100644
index 0000000..2f14567
--- /dev/null
+++ b/tests/CP/NavTest.php
@@ -0,0 +1,92 @@
+andReturn(collect())` (vendor:37-38)
+ * AFTER providers boot. Statamic::runBootedCallbacks() fires during
+ * $app->booted() (inside parent::setUp()), so Nav::extend() is called on
+ * the REAL Nav facade before any Mockery mock is installed. Additionally,
+ * Laravel's RegisterFacades::bootstrap() calls Facade::clearResolvedInstances()
+ * at app-creation time, which wipes any pre-boot spy — making shouldHaveReceived
+ * on the boot-time call impossible.
+ *
+ * Rule 1 deviation from plan's `Nav::shouldHaveReceived('extend')->once()`:
+ * We verify the nav extension MECHANISM directly by invoking the provider's
+ * private registerCpNav() method via Reflection on a fresh provider instance,
+ * with the Mockery mock NOW in place (post-boot). This proves the provider
+ * correctly calls Nav::extend() with a closure — equivalent behavior to boot.
+ */
+it('calls Nav::extend during provider boot to register the sidebar entry (D-03)', function (): void {
+ // Allow 'extend' on the existing Nav mock (AddonTestCase installs build/clearCachedUrls).
+ Nav::shouldReceive('extend')->once();
+
+ // Invoke registerCpNav() via Reflection on a fresh provider instance.
+ // This re-executes the provider's nav-registration code against the mock that is now
+ // in place, proving the correct Nav::extend() call shape.
+ $provider = new StatamicMailerLiteServiceProvider($this->app);
+ $reflection = new ReflectionClass($provider);
+ $method = $reflection->getMethod('registerCpNav');
+ $method->setAccessible(true);
+ $method->invoke($provider);
+
+ // Mockery enforces ->once() at teardown if extend was not called.
+ Nav::shouldHaveReceived('extend')->once();
+});
+
+it('registers MailerLite under a Concept7 parent in the Top Level section (Phase 05.1 follow-up)', function (): void {
+ $captured = null;
+
+ Nav::shouldReceive('extend')->once()->andReturnUsing(function (Closure $callback) use (&$captured): void {
+ $captured = $callback;
+ });
+
+ $provider = new StatamicMailerLiteServiceProvider($this->app);
+ $reflection = new ReflectionClass($provider);
+ $method = $reflection->getMethod('registerCpNav');
+ $method->setAccessible(true);
+ $method->invoke($provider);
+
+ expect($captured)->not->toBeNull();
+
+ // Child item (MailerLite) — must be created first because it's passed to
+ // the parent's ->children() call. Configured with mail icon + route.
+ $mailerLiteItem = Mockery::mock();
+ $mailerLiteItem->shouldReceive('route')->once()->with('mailerlite.dashboard')->andReturnSelf();
+ $mailerLiteItem->shouldReceive('icon')->once()->with('mail')->andReturnSelf();
+ $mailerLiteItem->shouldReceive('can')->once()->with('configure mailerlite')->andReturnSelf();
+
+ // Parent item (Concept7) — section('Top Level'), c7 inline SVG icon,
+ // permission gate, then children([mailerLiteItem]).
+ $concept7Item = Mockery::mock();
+ $concept7Item->shouldReceive('section')->once()->with('Top Level')->andReturnSelf();
+ $concept7Item->shouldReceive('icon')->once()->withArgs(function ($arg) {
+ // c7 inline SVG must start with `andReturnSelf();
+ $concept7Item->shouldReceive('can')->once()->with('configure mailerlite')->andReturnSelf();
+ $concept7Item->shouldReceive('children')->once()->withArgs(function ($arg) use ($mailerLiteItem) {
+ return is_array($arg) && count($arg) === 1 && $arg[0] === $mailerLiteItem;
+ })->andReturnSelf();
+
+ // $nav stub — create(Concept7) returns parent, item(MailerLite) returns child.
+ $nav = Mockery::mock();
+ $nav->shouldReceive('create')->once()->with(__('Concept7'))->andReturn($concept7Item);
+ $nav->shouldReceive('item')->once()->with(__('MailerLite'))->andReturn($mailerLiteItem);
+
+ $captured($nav);
+});
+
+it('does NOT register MailerLite under the Tools section (Phase 03 nav removed by D-13)', function (): void {
+ $providerSource = file_get_contents(__DIR__.'/../../src/StatamicMailerLiteServiceProvider.php');
+ expect($providerSource)->not->toContain("__('Tools')");
+ expect($providerSource)->not->toContain("section('Tools')");
+});
diff --git a/tests/CP/PermissionTest.php b/tests/CP/PermissionTest.php
new file mode 100644
index 0000000..438515d
--- /dev/null
+++ b/tests/CP/PermissionTest.php
@@ -0,0 +1,27 @@
+flattened()
+ $hasPermission = Permission::all()->contains(function ($permission): bool {
+ return $permission->value() === 'configure mailerlite';
+ });
+
+ expect($hasPermission)->toBeTrue();
+});
+
+it('groups the permission under the mailerlite label', function (): void {
+ // Permission::tree() returns a collection of arrays, each with 'handle', 'label',
+ // and 'permissions' keys (vendor Auth/Permissions.php:93-115). The plan referenced
+ // $group->value() but Permission::tree() returns plain arrays, not Permission objects.
+ // Rule 1 deviation: use array access on 'handle' instead.
+ $groups = Permission::tree();
+ $hasGroup = collect($groups)->contains(function ($group): bool {
+ return ($group['handle'] ?? null) === 'mailerlite';
+ });
+
+ expect($hasGroup)->toBeTrue();
+});
diff --git a/tests/CP/RateLimiterRegistrationTest.php b/tests/CP/RateLimiterRegistrationTest.php
new file mode 100644
index 0000000..b96616e
--- /dev/null
+++ b/tests/CP/RateLimiterRegistrationTest.php
@@ -0,0 +1,50 @@
+not->toBeNull();
+ expect(is_callable($limiter))->toBeTrue();
+});
+
+it('the mailerlite-retry limiter returns Limit::perMinute(5) keyed by user id', function (): void {
+ $limiter = RateLimiter::limiter('mailerlite-retry');
+
+ $user = new class
+ {
+ public function getAuthIdentifier(): string
+ {
+ return '42';
+ }
+ };
+
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('user')->andReturn($user);
+
+ $limit = $limiter($request);
+
+ expect($limit)->toBeInstanceOf(Limit::class);
+ expect($limit->maxAttempts)->toBe(5);
+ expect($limit->decaySeconds)->toBe(60);
+ expect($limit->key)->toContain('42');
+});
+
+it('the mailerlite-retry limiter handles a null user defensively', function (): void {
+ $limiter = RateLimiter::limiter('mailerlite-retry');
+
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('user')->andReturn(null);
+
+ $limit = $limiter($request);
+ expect($limit)->toBeInstanceOf(Limit::class);
+});
+
+afterEach(function (): void {
+ Mockery::close();
+});
diff --git a/tests/CP/RetryControllerTest.php b/tests/CP/RetryControllerTest.php
new file mode 100644
index 0000000..d79f252
--- /dev/null
+++ b/tests/CP/RetryControllerTest.php
@@ -0,0 +1,333 @@
+email('admin+permitted@example.com')
+ ->set('name', 'Permitted Admin')
+ ->save();
+ Role::make()
+ ->handle('mailerlite_admin')
+ ->title('MailerLite Admin')
+ ->addPermission('access cp')
+ ->addPermission('configure mailerlite')
+ ->save();
+ $user->assignRole('mailerlite_admin')->save();
+
+ return $user;
+}
+
+function makeRetrySuperUser(): Statamic\Contracts\Auth\User
+{
+ return User::make()
+ ->email('super@example.com')
+ ->set('name', 'Super')
+ ->makeSuper()
+ ->save();
+}
+
+function makeRetryNonPermittedUser(): Statamic\Contracts\Auth\User
+{
+ $user = User::make()
+ ->email('regular@example.com')
+ ->set('name', 'Regular')
+ ->save();
+ Role::make()
+ ->handle('cp_only')
+ ->title('CP Only')
+ ->addPermission('access cp')
+ ->save();
+ $user->assignRole('cp_only')->save();
+
+ return $user;
+}
+
+/**
+ * Build a FakeSubmission carrying ONE `mailerlite_sync.attempts[]` entry.
+ *
+ * @param array{
+ * submission_id?: string,
+ * status?: string,
+ * payload?: array,
+ * error_message?: string|null,
+ * } $attrs
+ */
+function seedRetryAttempt(array $attrs = []): FakeSubmission
+{
+ $submissionId = (string) ($attrs['submission_id'] ?? 'sub-original');
+ $attempt = [
+ 'id' => (string) Str::uuid(),
+ 'attempted_at' => Carbon::now()->toIso8601String(),
+ 'status' => (string) ($attrs['status'] ?? 'permanent_failure'),
+ 'payload' => isset($attrs['payload']) && is_array($attrs['payload']) ? $attrs['payload'] : [],
+ 'error_message' => array_key_exists('error_message', $attrs)
+ ? (is_string($attrs['error_message']) ? $attrs['error_message'] : null)
+ : null,
+ ];
+
+ return new FakeSubmission(
+ id: $submissionId,
+ form: Mockery::mock(FormContract::class), // placeholder, replaced by makeFakeForm
+ data: ['mailerlite_sync' => ['attempts' => [$attempt]]],
+ );
+}
+
+/**
+ * Wire a primary form (with optional submissions) into the Form facade. Pass
+ * extras keyed by handle for multi-form tests (e.g. cross-form 404 path).
+ *
+ * @param array $submissions
+ * @param array}> $extras
+ */
+function bindRetryForm(string $handle, array $submissions = [], array $extras = []): void
+{
+ $primary = makeFakeForm($handle, $submissions);
+ $forms = [$handle => $primary];
+ foreach ($extras as $extraHandle => $extraSpec) {
+ $forms[$extraHandle] = makeFakeForm(
+ $extraHandle,
+ $extraSpec['submissions'] ?? [],
+ );
+ }
+ bindFormFacade($forms);
+}
+
+/**
+ * Bind a StorageDriverInterface mock so RetryController can read the per-form
+ * `enabled` flag (WR-04). Defaults to enabled=true.
+ *
+ * @param array{enabled?: bool, groups?: array, fields?: array} $config
+ */
+function bindRetryDriver(string $handle, array $config = ['enabled' => true, 'groups' => [], 'fields' => []]): MockInterface
+{
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')->andReturnUsing(function ($requested) use ($handle, $config) {
+ return $requested === $handle ? $config : [];
+ });
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ return $driver;
+}
+
+/**
+ * Resolve the single ActivityLogEntry seeded by seedRetryAttempt() — used so
+ * test call sites don't need to know the attempt UUID up-front.
+ */
+function resolveSeededEntry(string $formHandle): ActivityLogEntry
+{
+ $entry = ActivityLogEntry::forForm($formHandle)->first();
+ expect($entry)->not->toBeNull();
+
+ return $entry;
+}
+
+// CP-05 — Retry endpoint
+it('store() dispatches SyncSubscriberJob with submissionData = stored payload (CP-05)', function (): void {
+ Bus::fake();
+ $sub = seedRetryAttempt([
+ 'payload' => [
+ 'email' => 'jane@example.com',
+ 'fields' => ['subscriber_name' => 'Jane'],
+ 'groups' => ['group-a'],
+ 'ip_address' => '203.0.113.7',
+ ],
+ 'error_message' => 'Original failure',
+ ]);
+ bindRetryForm('contact', [$sub]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertOk();
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job) use ($entry): bool {
+ return $job->formHandle === 'contact'
+ && $job->submissionData === $entry->payload;
+ });
+});
+
+it('store() returns JsonResponse with success:true and message', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertOk()
+ ->assertHeader('Content-Type', 'application/json')
+ ->assertJson(['success' => true]);
+});
+
+// T-04-RETRY-DOUBLE-MAP — guards against re-running mapFields
+it('store() does NOT pass null as submissionData — guards against re-running mapFields against stale on-disk submission', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]));
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionData !== null && is_array($job->submissionData);
+ });
+});
+
+it('store() entry-form mismatch returns 404 — attempt UUID does not exist within the URL form scope (T-04-IDOR)', function (): void {
+ Bus::fake();
+ // URL targets 'newsletter' but the attempt was seeded on 'contact'.
+ $contactSub = seedRetryAttempt();
+ bindRetryForm('newsletter', [], [
+ 'contact' => ['submissions' => [$contactSub]],
+ ]);
+ bindRetryDriver('newsletter');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'newsletter', 'log' => $entry->id]))
+ ->assertNotFound();
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('store() returns 404 when entry id does not exist', function (): void {
+ Bus::fake();
+ bindRetryForm('contact');
+ bindRetryDriver('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => '00000000-0000-0000-0000-000000000000']))
+ ->assertNotFound();
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('store() applies throttle:mailerlite-retry middleware (D-11)', function (): void {
+ $route = Route::getRoutes()->getByName('statamic.cp.mailerlite.activity.retry');
+ expect($route->middleware())->toContain('throttle:mailerlite-retry');
+});
+
+it('store() rate-limited 6th call within 60s returns 429', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+ $user = makeRetryPermittedUser();
+ $url = cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]);
+
+ for ($i = 1; $i <= 5; $i++) {
+ $this->actingAs($user, 'web')->post($url)->assertOk();
+ }
+ $this->actingAs($user, 'web')->post($url)->assertStatus(429);
+});
+
+it('store() returns 200 for permitted user dispatching a real failure row', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertOk();
+});
+
+// WR-04 regression — retries must honor the form's current `enabled` flag.
+it('store() returns 422 and does NOT dispatch the job when form has enabled=false (WR-04)', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact', ['enabled' => false, 'groups' => [], 'fields' => []]);
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertStatus(422)
+ ->assertJson(['success' => false]);
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+// WR-07 — the fallback `retry-{row->id}` path is dead under D-05 because every
+// attempt now lives on a real submission with a known id (`forSubmission()`
+// stamps the submission_id into the entry). The fallback survives as
+// defense-in-depth against pathological data; this test pins that behavior.
+it('store() preserves the submission id from the seeded attempt (D-05)', function (): void {
+ Bus::fake();
+ $sub = seedRetryAttempt([
+ 'submission_id' => 'real-submission-id',
+ 'payload' => ['email' => 'x@y.com', 'fields' => [], 'groups' => []],
+ ]);
+ bindRetryForm('contact', [$sub]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertOk();
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionId === 'real-submission-id';
+ });
+});
+
+it('store() returns 422 when form config is missing entirely — enabled defaults to false (WR-04)', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('other-form'); // driver returns [] for 'contact' (handle mismatch)
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertStatus(422);
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('store() redirects non-permitted user (CP redirect pattern)', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetryNonPermittedUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertRedirect();
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('store() returns 200 for super-user (bypass)', function (): void {
+ Bus::fake();
+ bindRetryForm('contact', [seedRetryAttempt()]);
+ bindRetryDriver('contact');
+ $entry = resolveSeededEntry('contact');
+
+ $this->actingAs(makeRetrySuperUser(), 'web')
+ ->post(cp_route('mailerlite.activity.retry', ['form' => 'contact', 'log' => $entry->id]))
+ ->assertOk();
+});
diff --git a/tests/CP/RouteRegistrationTest.php b/tests/CP/RouteRegistrationTest.php
new file mode 100644
index 0000000..d9096df
--- /dev/null
+++ b/tests/CP/RouteRegistrationTest.php
@@ -0,0 +1,65 @@
+name('statamic.cp.')).
+ *
+ * Our routes/cp.php declares ->name('mailerlite.dashboard') etc., but
+ * Statamic's outer group prepends 'statamic.cp.' giving the full name:
+ * statamic.cp.mailerlite.dashboard
+ * statamic.cp.mailerlite.activity.show
+ * statamic.cp.mailerlite.activity.retry
+ *
+ * Settings UI is registered separately via the Statamic native addon-settings
+ * blueprint (resources/blueprints/settings.yaml) and lives under Statamic's
+ * own Tools → Addons URL — no /cp/mailerlite/settings route here.
+ */
+it('registers the statamic.cp.mailerlite.dashboard named route', function (): void {
+ expect(Route::has('statamic.cp.mailerlite.dashboard'))->toBeTrue();
+});
+
+it('registers the statamic.cp.mailerlite.activity.show named route', function (): void {
+ expect(Route::has('statamic.cp.mailerlite.activity.show'))->toBeTrue();
+});
+
+it('registers the statamic.cp.mailerlite.activity.retry named route', function (): void {
+ expect(Route::has('statamic.cp.mailerlite.activity.retry'))->toBeTrue();
+});
+
+it('dashboard route uses GET HTTP method', function (): void {
+ $route = Route::getRoutes()->getByName('statamic.cp.mailerlite.dashboard');
+ expect($route->methods())->toContain('GET');
+});
+
+it('activity.show route uses GET HTTP method', function (): void {
+ $route = Route::getRoutes()->getByName('statamic.cp.mailerlite.activity.show');
+ expect($route->methods())->toContain('GET');
+});
+
+it('activity.retry route uses POST HTTP method', function (): void {
+ $route = Route::getRoutes()->getByName('statamic.cp.mailerlite.activity.retry');
+ expect($route->methods())->toContain('POST');
+});
+
+it('all addon routes carry the can:configure mailerlite middleware', function (): void {
+ foreach (['dashboard', 'activity.show', 'activity.retry'] as $name) {
+ $route = Route::getRoutes()->getByName("statamic.cp.mailerlite.{$name}");
+ expect($route->middleware())->toContain('can:configure mailerlite');
+ }
+});
+
+it('activity.retry route additionally carries throttle:mailerlite-retry (D-11)', function (): void {
+ $route = Route::getRoutes()->getByName('statamic.cp.mailerlite.activity.retry');
+ expect($route->middleware())->toContain('throttle:mailerlite-retry');
+});
+
+it('addon does NOT register a custom mailerlite.settings.* route (uses Statamic native settings)', function (): void {
+ expect(Route::has('statamic.cp.mailerlite.settings.edit'))->toBeFalse();
+ expect(Route::has('statamic.cp.mailerlite.settings.update'))->toBeFalse();
+ expect(Route::has('statamic.cp.mailerlite.settings.test-connection'))->toBeFalse();
+});
diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php
deleted file mode 100644
index 5d36321..0000000
--- a/tests/ExampleTest.php
+++ /dev/null
@@ -1,5 +0,0 @@
-toBeTrue();
-});
diff --git a/tests/Fieldtypes/MailerLiteFieldFieldtypeTest.php b/tests/Fieldtypes/MailerLiteFieldFieldtypeTest.php
new file mode 100644
index 0000000..c38ea5a
--- /dev/null
+++ b/tests/Fieldtypes/MailerLiteFieldFieldtypeTest.php
@@ -0,0 +1,196 @@
+hasProperty('cache')) {
+ $p = $r->getProperty('cache');
+ $p->setAccessible(true);
+ $p->setValue(null, []);
+ }
+});
+
+function makeMailerLiteFieldFieldtype(): MailerLiteFieldFieldtype
+{
+ // Statamic Fieldtypes have a complex constructor surface — instantiating
+ // bare may not exercise the parent's wiring. For unit testing the override
+ // methods, a bare new is sufficient because we don't call the inherited
+ // dropdown-render path.
+ return new MailerLiteFieldFieldtype;
+}
+
+it('declares static handle = mailerlite_field (D-18 auto-discovery key)', function (): void {
+ $reflection = new ReflectionClass(MailerLiteFieldFieldtype::class);
+ $handle = $reflection->getStaticPropertyValue('handle');
+ expect($handle)->toBe('mailerlite_field');
+});
+
+it('extends Statamic\\Fieldtypes\\Relationship (D-13)', function (): void {
+ expect(is_subclass_of(MailerLiteFieldFieldtype::class, Relationship::class))->toBeTrue();
+});
+
+it('is a final class', function (): void {
+ $reflection = new ReflectionClass(MailerLiteFieldFieldtype::class);
+ expect($reflection->isFinal())->toBeTrue();
+});
+
+it('getIndexItems returns email baseline + live-fetched items mapped from listFields (FT-01 + FT-05)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('valid-key-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listFields')->with('valid-key-1234567890123456789012345678')->andReturn([
+ ['id' => 'first_name', 'title' => 'First Name'],
+ ['id' => 'company', 'title' => 'Company'],
+ ]);
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ $items = makeMailerLiteFieldFieldtype()->getIndexItems(null);
+
+ expect($items)->toBeInstanceOf(Collection::class);
+ $values = $items->values()->all();
+ expect($values[0])->toBe(['id' => 'email', 'title' => 'Email (built-in)']);
+ expect($values[1])->toBe(['id' => 'first_name', 'title' => 'First Name']);
+ expect($values[2])->toBe(['id' => 'company', 'title' => 'Company']);
+});
+
+it('getIndexItems returns email-only when API key is empty (no log warning) (D-17 + FT-04)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('listFields');
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = makeMailerLiteFieldFieldtype()->getIndexItems(null);
+ $values = $items->values()->all();
+
+ expect($values)->toBe([['id' => 'email', 'title' => 'Email (built-in)']]);
+ Log::shouldNotHaveReceived('warning');
+});
+
+it('getIndexItems returns email-only + warning on MailerLiteValidationException (D-17 + FT-04)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('invalid-key-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listFields')->andThrow(new MailerLiteValidationException('bad key', 422));
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = makeMailerLiteFieldFieldtype()->getIndexItems(null);
+ expect($items->values()->all())->toBe([['id' => 'email', 'title' => 'Email (built-in)']]);
+
+ Log::shouldHaveReceived('warning')
+ ->with('MailerLite fieldtype fetch failed', Mockery::on(function ($context): bool {
+ return ($context['fieldtype'] ?? '') === MailerLiteFieldFieldtype::class
+ && isset($context['message']);
+ }))
+ ->once();
+});
+
+it('getIndexItems returns email-only + warning on MailerLiteHttpException (D-17 + FT-04)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('any-key-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listFields')->andThrow(new MailerLiteHttpException('Service Unavailable', 503));
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = makeMailerLiteFieldFieldtype()->getIndexItems(null);
+ expect($items->values()->all())->toBe([['id' => 'email', 'title' => 'Email (built-in)']]);
+
+ Log::shouldHaveReceived('warning')->once();
+});
+
+it("toItemArray('email') returns the synthetic baseline (FT-05 + D-16)", function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $reflection = new ReflectionClass(MailerLiteFieldFieldtype::class);
+ $method = $reflection->getMethod('toItemArray');
+ $method->setAccessible(true);
+
+ $result = $method->invoke(makeMailerLiteFieldFieldtype(), 'email');
+ expect($result)->toBe(['id' => 'email', 'title' => 'Email (built-in)']);
+});
+
+it('toItemArray for an unknown id returns ["id" => $id, "title" => $id] fallback', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $reflection = new ReflectionClass(MailerLiteFieldFieldtype::class);
+ $method = $reflection->getMethod('toItemArray');
+ $method->setAccessible(true);
+
+ $result = $method->invoke(makeMailerLiteFieldFieldtype(), 'orphan_handle');
+ expect($result)->toBe(['id' => 'orphan_handle', 'title' => 'orphan_handle']);
+});
+
+it('per-request cache uses sha256 of api key as key (T-FT-CACHE-CROSS-TENANT)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('cached-key-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $callCount = 0;
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listFields')->andReturnUsing(function () use (&$callCount) {
+ $callCount++;
+
+ return [['id' => 'a', 'title' => 'A']];
+ });
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ // Two calls, same api key -> SDK hit ONCE.
+ makeMailerLiteFieldFieldtype()->getIndexItems(null);
+ makeMailerLiteFieldFieldtype()->getIndexItems(null);
+
+ expect($callCount)->toBe(1);
+});
+
+it('per-request cache distinguishes by api key (cache miss when key changes within same request — Phase 5 forward-compat)', function (): void {
+ $apiKey = 'first-key-1234567890123456789012345678';
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturnUsing(function () use (&$apiKey) {
+ return $apiKey;
+ });
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $callCount = 0;
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listFields')->andReturnUsing(function () use (&$callCount) {
+ $callCount++;
+
+ return [['id' => 'a', 'title' => 'A']];
+ });
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ makeMailerLiteFieldFieldtype()->getIndexItems(null);
+ $apiKey = 'second-key-1234567890123456789012345678';
+ makeMailerLiteFieldFieldtype()->getIndexItems(null);
+
+ expect($callCount)->toBe(2); // Different keys -> two SDK calls
+});
diff --git a/tests/Fieldtypes/MailerLiteGroupFieldtypeTest.php b/tests/Fieldtypes/MailerLiteGroupFieldtypeTest.php
new file mode 100644
index 0000000..9a8cb69
--- /dev/null
+++ b/tests/Fieldtypes/MailerLiteGroupFieldtypeTest.php
@@ -0,0 +1,140 @@
+hasProperty('cache')) {
+ $p = $r->getProperty('cache');
+ $p->setAccessible(true);
+ $p->setValue(null, []);
+ }
+});
+
+it('declares static handle = mailerlite_group (D-18)', function (): void {
+ $reflection = new ReflectionClass(MailerLiteGroupFieldtype::class);
+ expect($reflection->getStaticPropertyValue('handle'))->toBe('mailerlite_group');
+});
+
+it('extends Statamic\\Fieldtypes\\Relationship (D-13) and is final', function (): void {
+ $reflection = new ReflectionClass(MailerLiteGroupFieldtype::class);
+ expect(is_subclass_of(MailerLiteGroupFieldtype::class, Relationship::class))->toBeTrue();
+ expect($reflection->isFinal())->toBeTrue();
+});
+
+it('getIndexItems returns mapped listGroups response (FT-02)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('valid-key-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listGroups')->andReturn([
+ ['id' => '1001', 'name' => 'Newsletter'],
+ ['id' => '1002', 'name' => 'Customers'],
+ ]);
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ $items = (new MailerLiteGroupFieldtype)->getIndexItems(null);
+ $values = $items->values()->all();
+
+ expect($items)->toBeInstanceOf(Collection::class);
+ expect(count($values))->toBe(2);
+ // Either the fieldtype keeps `name` or maps to `title` — both are acceptable
+ // depending on Statamic's Relationship contract. Verify the id at least.
+ expect($values[0]['id'])->toBe('1001');
+ expect($values[1]['id'])->toBe('1002');
+});
+
+it('getIndexItems returns [] when API key is empty (no warning) (D-17 + FT-04)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('listGroups');
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = (new MailerLiteGroupFieldtype)->getIndexItems(null);
+
+ expect($items->all())->toBe([]);
+ Log::shouldNotHaveReceived('warning');
+});
+
+it('getIndexItems returns [] + warning on MailerLiteValidationException (D-17)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('invalid-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listGroups')->andThrow(new MailerLiteValidationException('bad', 422));
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = (new MailerLiteGroupFieldtype)->getIndexItems(null);
+
+ expect($items->all())->toBe([]);
+ Log::shouldHaveReceived('warning')->once();
+});
+
+it('getIndexItems returns [] + warning on MailerLiteHttpException (D-17)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('any-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listGroups')->andThrow(new MailerLiteHttpException('Service Unavailable', 503));
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ Log::spy();
+
+ $items = (new MailerLiteGroupFieldtype)->getIndexItems(null);
+
+ expect($items->all())->toBe([]);
+ Log::shouldHaveReceived('warning')->once();
+});
+
+it('per-request cache uses sha256 of api key (T-FT-CACHE-CROSS-TENANT)', function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('cached-1234567890123456789012345678');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $callCount = 0;
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('listGroups')->andReturnUsing(function () use (&$callCount) {
+ $callCount++;
+
+ return [['id' => '1', 'name' => 'A']];
+ });
+ app()->instance(MailerLiteServiceInterface::class, $service);
+
+ (new MailerLiteGroupFieldtype)->getIndexItems(null);
+ (new MailerLiteGroupFieldtype)->getIndexItems(null);
+
+ expect($callCount)->toBe(1);
+});
+
+it("toItemArray for an unknown id returns ['id' => \$id, 'title' => \$id] fallback", function (): void {
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('');
+ app()->instance(StorageDriverInterface::class, $driver);
+
+ $reflection = new ReflectionClass(MailerLiteGroupFieldtype::class);
+ $method = $reflection->getMethod('toItemArray');
+ $method->setAccessible(true);
+
+ $result = $method->invoke(new MailerLiteGroupFieldtype, '1099');
+ expect($result['id'])->toBe('1099');
+});
diff --git a/tests/Fixtures/FakeSubmission.php b/tests/Fixtures/FakeSubmission.php
new file mode 100644
index 0000000..d8fbbd3
--- /dev/null
+++ b/tests/Fixtures/FakeSubmission.php
@@ -0,0 +1,125 @@
+ */
+ private array $store = [];
+
+ private bool $saved = false;
+
+ private ?\Throwable $saveError = null;
+
+ public function __construct(
+ private string $id,
+ private FormContract $form,
+ array $data = [],
+ ) {
+ $this->store = $data;
+ }
+
+ public function throwOnSave(\Throwable $exception): self
+ {
+ $this->saveError = $exception;
+
+ return $this;
+ }
+
+ public function id($id = null)
+ {
+ if ($id !== null) {
+ $this->id = (string) $id;
+ }
+
+ return $this->id;
+ }
+
+ public function form($form = null)
+ {
+ if ($form !== null) {
+ $this->form = $form;
+ }
+
+ return $this->form;
+ }
+
+ public function fields(): array
+ {
+ return [];
+ }
+
+ public function columns(): array
+ {
+ return [];
+ }
+
+ public function date(): Carbon
+ {
+ return Carbon::createFromTimestamp((int) (float) $this->id);
+ }
+
+ public function data($data = null)
+ {
+ if ($data !== null) {
+ if ($data instanceof Collection) {
+ $this->store = $data->all();
+ } elseif (is_array($data)) {
+ $this->store = $data;
+ }
+ }
+
+ return new Collection($this->store);
+ }
+
+ public function get($field)
+ {
+ return $this->store[$field] ?? null;
+ }
+
+ public function set($field, $value)
+ {
+ $this->store[$field] = $value;
+
+ return $this;
+ }
+
+ public function delete(): bool
+ {
+ return true;
+ }
+
+ public function save(): void
+ {
+ if ($this->saveError !== null) {
+ throw $this->saveError;
+ }
+ $this->saved = true;
+ }
+
+ public function saveWasCalled(): bool
+ {
+ return $this->saved;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->store;
+ }
+}
diff --git a/tests/Forms/AppendConfigFieldsTest.php b/tests/Forms/AppendConfigFieldsTest.php
new file mode 100644
index 0000000..4d77b23
--- /dev/null
+++ b/tests/Forms/AppendConfigFieldsTest.php
@@ -0,0 +1,89 @@
+contains(function ($section): bool {
+ return ($section['display'] ?? null) === 'MailerLite';
+ });
+
+ expect($hasSection)->toBeTrue();
+});
+
+it('section has three top-level fields: enabled, mappings, groups (D-06)', function (): void {
+ $sections = Form::extraConfigFor('contact');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+
+ $fieldHandles = array_keys($section['fields']);
+
+ expect($fieldHandles)->toBe(['enabled', 'mappings', 'groups']);
+});
+
+it('enabled field is a toggle with the locked label and default false (D-06 + UI-SPEC)', function (): void {
+ $sections = Form::extraConfigFor('contact');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+ $enabled = $section['fields']['enabled'];
+
+ expect($enabled['type'])->toBe('toggle');
+ expect($enabled['display'])->toBe('MailerLite sync');
+ expect($enabled['default'])->toBeFalse();
+ expect($enabled['instructions'])
+ ->toBe('When enabled, submissions to this form sync to MailerLite as subscribers.');
+});
+
+it('mappings field is a replicator with one set named "mapping" containing form_field + mailerlite_field children', function (): void {
+ $sections = Form::extraConfigFor('contact');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+ $mappings = $section['fields']['mappings'];
+
+ expect($mappings['type'])->toBe('replicator');
+ expect($mappings['display'])->toBe('Field mapping');
+ expect($mappings['instructions'])->toBe('Add a row to map a form field to a MailerLite subscriber field.');
+
+ $mappingSet = $mappings['sets']['main']['sets']['mapping'] ?? null;
+ expect($mappingSet)->not->toBeNull();
+
+ $childHandles = array_map(fn ($f) => $f['handle'], $mappingSet['fields']);
+ expect($childHandles)->toBe(['form_field', 'mailerlite_field']);
+});
+
+it('mappings.mailerlite_field child uses the custom mailerlite_field fieldtype (D-06)', function (): void {
+ $sections = Form::extraConfigFor('contact');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+ $mlField = $section['fields']['mappings']['sets']['main']['sets']['mapping']['fields'][1];
+
+ expect($mlField['handle'])->toBe('mailerlite_field');
+ expect($mlField['field']['type'])->toBe('mailerlite_field');
+});
+
+it('groups field uses the custom mailerlite_group fieldtype (D-06 + FT-02)', function (): void {
+ $sections = Form::extraConfigFor('contact');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+ $groups = $section['fields']['groups'];
+
+ expect($groups['type'])->toBe('mailerlite_group');
+ expect($groups['display'])->toBe('Groups');
+ expect($groups['instructions'])->toBe('Subscribers will be added to every group selected here.');
+});
+
+it('section uses none of the reserved Statamic form-config handles (title, honeypot, store, email)', function (): void {
+ // RESEARCH Anti-Patterns line 452: reserved keys are silently dropped
+ // (FormRepository.php:99). Defensive test ensuring our handles dodge them.
+ $sections = Form::extraConfigFor('any');
+ $section = collect($sections)->first(fn ($s) => ($s['display'] ?? null) === 'MailerLite');
+ $reserved = ['title', 'honeypot', 'store', 'email'];
+
+ foreach (array_keys($section['fields']) as $handle) {
+ expect($reserved)->not->toContain($handle);
+ }
+});
diff --git a/tests/Forms/FormSavedListenerTest.php b/tests/Forms/FormSavedListenerTest.php
new file mode 100644
index 0000000..557a97b
--- /dev/null
+++ b/tests/Forms/FormSavedListenerTest.php
@@ -0,0 +1,163 @@
+shouldReceive('handle')->andReturn($handle);
+ $form->shouldReceive('get')->with('enabled')
+ ->andReturn($config['enabled'] ?? null);
+ $form->shouldReceive('get')->with('mappings')
+ ->andReturn($config['mappings'] ?? null);
+ $form->shouldReceive('get')->with('groups')
+ ->andReturn($config['groups'] ?? null);
+
+ return $form;
+}
+
+it('calls saveFormConfig EXACTLY ONCE when MailerLite is enabled (D-21)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('contact', [
+ 'enabled' => true,
+ 'mappings' => [
+ ['form_field' => 'name', 'mailerlite_field' => 'subscriber_name'],
+ ],
+ 'groups' => ['1001'],
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('saveFormConfig')
+ ->with('contact', Mockery::on(function ($data): bool {
+ return $data['enabled'] === true
+ && isset($data['fields'])
+ && isset($data['groups'])
+ && $data['fields'][0]['form_field'] === 'name';
+ }))
+ ->times(1);
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('does NOT call saveFormConfig when enabled is false (FORM-05)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('disabled-form', [
+ 'enabled' => false,
+ 'mappings' => [],
+ 'groups' => [],
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldNotReceive('saveFormConfig');
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('does NOT call saveFormConfig when MailerLite section is absent / null values (FORM-05)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('untouched-form', [
+ // All keys null — Statamic returns null for get() on a key not in form data
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldNotReceive('saveFormConfig');
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('does NOT call saveFormConfig when enabled is truthy-but-not-true (D-19 strict comparison)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('weird-form', [
+ 'enabled' => 1, // truthy int, not strict bool true
+ 'mappings' => [],
+ 'groups' => [],
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldNotReceive('saveFormConfig');
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('rekeys mappings -> fields when calling saveFormConfig (Phase 1 D-02 storage contract)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('mapping-form', [
+ 'enabled' => true,
+ 'mappings' => [
+ ['form_field' => 'email', 'mailerlite_field' => 'email'],
+ ['form_field' => 'first_name', 'mailerlite_field' => 'name'],
+ ],
+ 'groups' => ['1001', '1002'],
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('saveFormConfig')
+ ->with('mapping-form', Mockery::on(function ($data): bool {
+ // The Phase 1 storage shape uses `fields` (plural), not `mappings`.
+ return array_key_exists('fields', $data)
+ && ! array_key_exists('mappings', $data)
+ && count($data['fields']) === 2
+ && $data['fields'][1]['form_field'] === 'first_name';
+ }))
+ ->once();
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('passes groups through unchanged (FORM-04)', function (): void {
+ $form = makeFormMockWithMailerLiteConfig('groups-form', [
+ 'enabled' => true,
+ 'mappings' => [],
+ 'groups' => ['100', '200', '300'],
+ ]);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('saveFormConfig')
+ ->with('groups-form', Mockery::on(function ($data): bool {
+ return $data['groups'] === ['100', '200', '300'];
+ }))
+ ->once();
+
+ (new FormSavedListener($driver))->handle(new FormSaved($form));
+});
+
+it('is final and does NOT implement ShouldQueue (synchronous run inside CP request)', function (): void {
+ $reflection = new ReflectionClass(FormSavedListener::class);
+
+ expect($reflection->isFinal())->toBeTrue();
+ expect($reflection->implementsInterface(ShouldQueue::class))->toBeFalse();
+});
+
+it('takes exactly one constructor parameter — private readonly StorageDriverInterface (D-22)', function (): void {
+ $reflection = new ReflectionClass(FormSavedListener::class);
+ $params = $reflection->getConstructor()->getParameters();
+
+ expect($params)->toHaveCount(1);
+ expect($params[0]->getName())->toBe('driver');
+ expect((string) $params[0]->getType())->toBe(StorageDriverInterface::class);
+
+ $property = $reflection->getProperty('driver');
+ expect($property->isReadOnly())->toBeTrue();
+ expect($property->isPrivate())->toBeTrue();
+});
+
+it('handle() takes a single FormSaved parameter and returns void', function (): void {
+ $method = new ReflectionMethod(FormSavedListener::class, 'handle');
+ $params = $method->getParameters();
+ expect($params)->toHaveCount(1);
+ expect((string) $params[0]->getType())->toBe(FormSaved::class);
+ expect((string) $method->getReturnType())->toBe('void');
+});
+
+it('Statamic auto-discovers FormSavedListener — Event::hasListeners(FormSaved) is true after boot', function (): void {
+ // Phase 2 P05 deviation precedent: src/Listeners/* are auto-discovered by
+ // Statamic\Providers\AddonServiceProvider::bootEvents() (vendor:279).
+ // No explicit Event::listen call needed in our provider.
+ expect(Event::hasListeners(FormSaved::class))->toBeTrue();
+});
diff --git a/tests/Listeners/FormSubmittedListenerTest.php b/tests/Listeners/FormSubmittedListenerTest.php
new file mode 100644
index 0000000..a5944e3
--- /dev/null
+++ b/tests/Listeners/FormSubmittedListenerTest.php
@@ -0,0 +1,59 @@
+ip() on the submission', function (): void {
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('set')
+ ->once()
+ ->with('ip_address', Mockery::any()) // request()->ip() may be null/string in test context
+ ->andReturnSelf();
+
+ $event = new FormSubmitted($submission);
+
+ (new FormSubmittedListener)->handle($event);
+});
+
+it('does not call save on the submission (avoid double-dispatch of SubmissionCreated)', function (): void {
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('set')->andReturnSelf();
+ $submission->shouldNotReceive('save');
+
+ $event = new FormSubmitted($submission);
+
+ (new FormSubmittedListener)->handle($event);
+});
+
+it('is a final class and does NOT implement ShouldQueue (must run synchronously)', function (): void {
+ $reflection = new ReflectionClass(FormSubmittedListener::class);
+
+ expect($reflection->isFinal())->toBeTrue();
+ expect($reflection->implementsInterface(ShouldQueue::class))->toBeFalse();
+});
+
+it('exposes handle as the only public method', function (): void {
+ $reflection = new ReflectionClass(FormSubmittedListener::class);
+ $publicMethods = array_filter(
+ $reflection->getMethods(ReflectionMethod::IS_PUBLIC),
+ fn (ReflectionMethod $m): bool => ! $m->isConstructor(),
+ );
+ $names = array_values(array_map(fn (ReflectionMethod $m): string => $m->getName(), $publicMethods));
+ expect($names)->toBe(['handle']);
+});
+
+it('handle() takes a single FormSubmitted parameter and returns void', function (): void {
+ $method = new ReflectionMethod(FormSubmittedListener::class, 'handle');
+ $params = $method->getParameters();
+ expect($params)->toHaveCount(1);
+ expect((string) $params[0]->getType())->toBe(FormSubmitted::class);
+ expect((string) $method->getReturnType())->toBe('void');
+});
diff --git a/tests/Listeners/SubmissionCreatedListenerTest.php b/tests/Listeners/SubmissionCreatedListenerTest.php
new file mode 100644
index 0000000..8f69cdd
--- /dev/null
+++ b/tests/Listeners/SubmissionCreatedListenerTest.php
@@ -0,0 +1,214 @@
+shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->shouldReceive('id')->andReturn('sub-001');
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')
+ ->with('contact')
+ ->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ $listener = new SubmissionCreatedListener($driver);
+ $listener->handle(new SubmissionCreated($submission));
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionId === 'sub-001'
+ && $job->formHandle === 'contact'
+ && $job->submissionData === null; // Q1 — store: true means data is null
+ });
+});
+
+it('does NOT dispatch when MailerLite is disabled for the form (success criterion #5)', function (): void {
+ Bus::fake();
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->allows('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->allows('id')->andReturn('sub-disabled');
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')
+ ->with('contact')
+ ->andReturn(['enabled' => false, 'groups' => [], 'fields' => []]);
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('does NOT dispatch when no MailerLite config exists for the form (success criterion #5)', function (): void {
+ Bus::fake();
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('newsletter');
+ $form->allows('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->allows('id')->andReturn('sub-noconfig');
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')
+ ->with('newsletter')
+ ->andReturn([]); // empty — no MailerLite config
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('Q1 Option A: passes submissionData array when form has store: false', function (): void {
+ Bus::fake();
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(false); // store: false
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->shouldReceive('id')->andReturn('sub-nostore');
+ $submission->shouldReceive('data')
+ ->andReturn(new Collection(['email' => 'jane@example.com', 'name' => 'Jane', 'ip_address' => '127.0.0.1']));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')
+ ->with('contact')
+ ->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionData === ['email' => 'jane@example.com', 'name' => 'Jane', 'ip_address' => '127.0.0.1']
+ && $job->submissionId === 'sub-nostore';
+ });
+});
+
+it('Q1 Option A: passes submissionData null when form has store: true (Job loads from disk)', function (): void {
+ Bus::fake();
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->shouldReceive('id')->andReturn('sub-stored');
+ $submission->shouldNotReceive('data'); // store: true — data not read in listener
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true]);
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionData === null;
+ });
+});
+
+it('does NOT implement ShouldQueue (only the Job is queued)', function (): void {
+ $reflection = new ReflectionClass(SubmissionCreatedListener::class);
+ expect($reflection->isFinal())->toBeTrue();
+ expect($reflection->implementsInterface(ShouldQueue::class))->toBeFalse();
+});
+
+it('takes exactly one constructor parameter — private readonly StorageDriverInterface', function (): void {
+ $reflection = new ReflectionClass(SubmissionCreatedListener::class);
+ $constructor = $reflection->getConstructor();
+ expect($constructor)->not->toBeNull();
+ $params = $constructor->getParameters();
+
+ expect($params)->toHaveCount(1);
+ expect($params[0]->getName())->toBe('driver');
+ expect((string) $params[0]->getType())->toBe(StorageDriverInterface::class);
+
+ $property = $reflection->getProperty('driver');
+ expect($property->isReadOnly())->toBeTrue();
+ expect($property->isPrivate())->toBeTrue();
+});
+
+it('handle() takes a single SubmissionCreated parameter and returns void', function (): void {
+ $method = new ReflectionMethod(SubmissionCreatedListener::class, 'handle');
+ $params = $method->getParameters();
+ expect($params)->toHaveCount(1);
+ expect((string) $params[0]->getType())->toBe(SubmissionCreated::class);
+ expect((string) $method->getReturnType())->toBe('void');
+});
+
+it('WR-06: accepts truthy-but-not-strict-true enabled values (YAML deserialization edge cases)', function (): void {
+ // Previously this test asserted strict bool-true rejection ("strict comparison
+ // per D-04"). WR-06 relaxes the check to PHP truthy semantics so common YAML
+ // deserialization shapes (1, "yes", "true", "1") work. The "should-be-enabled
+ // but invisible" failure mode is the worst kind — silent no-op with no
+ // activity log row.
+ Bus::fake();
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->shouldReceive('id')->andReturn('sub-truthy');
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getFormConfig')
+ ->andReturn(['enabled' => 1]); // truthy int — should NOW dispatch
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+
+ Bus::assertDispatched(SyncSubscriberJob::class);
+});
+
+it('WR-06: still rejects falsy enabled values (false, 0, "", null, missing)', function (): void {
+ Bus::fake();
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+
+ // Falsy values must still bail out — D-04's "do not sync if config is
+ // uncertain" semantics survive: only the strict-bool requirement is
+ // relaxed; falsy still means "don't sync".
+ foreach ([false, 0, '', null] as $i => $falsyValue) {
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn("falsy-form-{$i}");
+ $form->allows('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->allows('id')->andReturn("sub-falsy-{$i}");
+
+ $driver->shouldReceive('getFormConfig')
+ ->with("falsy-form-{$i}")
+ ->andReturn(['enabled' => $falsyValue]);
+
+ (new SubmissionCreatedListener($driver))->handle(new SubmissionCreated($submission));
+ }
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
diff --git a/tests/MailerLite/ExceptionAdapterTest.php b/tests/MailerLite/ExceptionAdapterTest.php
new file mode 100644
index 0000000..c37e954
--- /dev/null
+++ b/tests/MailerLite/ExceptionAdapterTest.php
@@ -0,0 +1,64 @@
+getMessage(), 422, $e)`).
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->allows('getContents')->andReturn('{"message":"The email field must be a valid email address."}');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->allows('getBody')->andReturn($stream);
+ $response->allows('getStatusCode')->andReturn(422);
+
+ $request = Mockery::mock(RequestInterface::class);
+
+ $sdkException = new SdkValidationException($request, $response);
+
+ $addonException = new MailerLiteValidationException($sdkException->getMessage(), 422, $sdkException);
+
+ expect($addonException)->toBeInstanceOf(MailerLiteValidationException::class);
+ expect($addonException->getCode())->toBe(422);
+ expect($addonException->getPrevious())->toBe($sdkException);
+ expect($addonException->getMessage())->toBe('The email field must be a valid email address.');
+});
+
+it('adapts SDK http exception to addon http exception preserving previous', function (): void {
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->allows('getContents')->andReturn('Service unavailable');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->allows('getBody')->andReturn($stream);
+ $response->allows('getStatusCode')->andReturn(503);
+ $response->allows('getReasonPhrase')->andReturn('Service Unavailable');
+
+ $request = Mockery::mock(RequestInterface::class);
+ $request->allows('getRequestTarget')->andReturn('/api/subscribers');
+ $request->allows('getMethod')->andReturn('POST');
+
+ $sdkException = new SdkHttpException($request, $response);
+
+ $addonException = new MailerLiteHttpException($sdkException->getMessage(), $sdkException->getCode(), $sdkException);
+
+ expect($addonException)->toBeInstanceOf(MailerLiteHttpException::class);
+ expect($addonException->getCode())->toBe(503);
+ expect($addonException->getPrevious())->toBe($sdkException);
+});
diff --git a/tests/MailerLite/ListFieldsTest.php b/tests/MailerLite/ListFieldsTest.php
new file mode 100644
index 0000000..dbc803a
--- /dev/null
+++ b/tests/MailerLite/ListFieldsTest.php
@@ -0,0 +1,189 @@
+fields = Mockery::mock();
+ $client->fields->shouldReceive('get')
+ ->with([])
+ ->andReturn(['status_code' => 200, 'body' => ['data' => $sdkBodyData]]);
+
+ return $client;
+}
+
+/**
+ * Helper: build a real SdkHttpException with the requested HTTP status code
+ * configured against the fields endpoint. Mirrors PingTest::makeSdkHttpExceptionWithStatus
+ * but is duplicated locally to keep each test file self-contained (Pest-style).
+ */
+function makeFieldsSdkHttpException(int $statusCode, string $reasonPhrase = 'Error'): SdkHttpException
+{
+ $request = Mockery::mock(RequestInterface::class);
+ $request->shouldReceive('getRequestTarget')->andReturn('/api/fields');
+ $request->shouldReceive('getMethod')->andReturn('GET');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn($statusCode);
+ $response->shouldReceive('getReasonPhrase')->andReturn($reasonPhrase);
+
+ return new SdkHttpException($request, $response);
+}
+
+it('maps SDK body.data fields to {id, title} pairs', function (): void {
+ $client = makeMailerLiteClientForFields([
+ ['key' => 'first_name', 'name' => 'First Name'],
+ ['key' => 'company', 'name' => 'Company'],
+ ['key' => 'phone', 'name' => 'Phone'],
+ ]);
+ $service = new MailerLiteService(fn () => $client);
+
+ $result = $service->listFields('valid-key');
+
+ expect($result)->toBe([
+ ['id' => 'first_name', 'title' => 'First Name'],
+ ['id' => 'company', 'title' => 'Company'],
+ ['id' => 'phone', 'title' => 'Phone'],
+ ]);
+});
+
+it('returns empty array when account has zero custom fields (no email synth here)', function (): void {
+ $client = makeMailerLiteClientForFields([]);
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->listFields('valid-key'))->toBe([]);
+});
+
+it('falls back to using key as title when name is absent', function (): void {
+ $client = makeMailerLiteClientForFields([
+ ['key' => 'orphan_field'], // no `name` key
+ ['key' => 'normal_field', 'name' => 'Normal Field'],
+ ]);
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->listFields('valid-key'))->toBe([
+ ['id' => 'orphan_field', 'title' => 'orphan_field'],
+ ['id' => 'normal_field', 'title' => 'Normal Field'],
+ ]);
+});
+
+it('re-throws SDK validation exception as addon MailerLiteValidationException', function (): void {
+ $request = Mockery::mock(RequestInterface::class);
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->shouldReceive('getContents')->andReturn('{"message":"invalid key"}');
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn(422);
+ $response->shouldReceive('getBody')->andReturn($stream);
+ $sdkException = new SdkValidationException($request, $response);
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listFields('bad-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+// UAT-02 regression — D-09 amended: 4xx HTTP responses are PERMANENT failures.
+// listFields() is called by the MailerLiteFieldFieldtype during CP form-edit
+// rendering; surfacing a 401 as Validation lets the fieldtype show an
+// actionable error rather than a transient "service unreachable" copy.
+
+it('UAT-02: re-throws SDK 401 from listFields as addon MailerLiteValidationException', function (): void {
+ $sdkException = makeFieldsSdkHttpException(401, 'Unauthorized');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listFields('invalid-key-xxx');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(401);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: re-throws SDK 403 from listFields as addon MailerLiteValidationException', function (): void {
+ $sdkException = makeFieldsSdkHttpException(403, 'Forbidden');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listFields('restricted-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(403);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('re-throws SDK 503 from listFields as addon MailerLiteHttpException (transient — retry)', function (): void {
+ $sdkException = makeFieldsSdkHttpException(503, 'Service Unavailable');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listFields('any-key');
+ expect(false)->toBeTrue('Expected MailerLiteHttpException');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(503);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('does NOT prepend synthetic email option (D-16 — that is fieldtype responsibility)', function (): void {
+ $source = file_get_contents(__DIR__.'/../../src/MailerLite/MailerLiteService.php');
+ $start = strpos($source, 'public function listFields(');
+ $end = strpos($source, 'public function ', $start + 1);
+ if ($end === false) {
+ $end = strlen($source);
+ }
+ $body = substr($source, $start, $end - $start);
+
+ expect(strtolower($body))->not->toContain("'email'");
+ expect(strtolower($body))->not->toContain('"email"');
+});
+
+it('skips malformed SDK rows missing key (defensive against API shape drift)', function (): void {
+ $client = makeMailerLiteClientForFields([
+ ['name' => 'No Key'], // missing key — must be skipped
+ ['key' => 'good', 'name' => 'Good'],
+ ]);
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->listFields('valid-key'))->toBe([
+ ['id' => 'good', 'title' => 'Good'],
+ ]);
+});
diff --git a/tests/MailerLite/ListGroupsTest.php b/tests/MailerLite/ListGroupsTest.php
new file mode 100644
index 0000000..ec4137b
--- /dev/null
+++ b/tests/MailerLite/ListGroupsTest.php
@@ -0,0 +1,161 @@
+groups = Mockery::mock();
+ $client->groups->shouldReceive('get')
+ ->with([])
+ ->andReturn(['status_code' => 200, 'body' => ['data' => $sdkBodyData]]);
+
+ return $client;
+}
+
+/**
+ * Helper: build a real SdkHttpException with the requested HTTP status code
+ * configured against the groups endpoint.
+ */
+function makeGroupsSdkHttpException(int $statusCode, string $reasonPhrase = 'Error'): SdkHttpException
+{
+ $request = Mockery::mock(RequestInterface::class);
+ $request->shouldReceive('getRequestTarget')->andReturn('/api/groups');
+ $request->shouldReceive('getMethod')->andReturn('GET');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn($statusCode);
+ $response->shouldReceive('getReasonPhrase')->andReturn($reasonPhrase);
+
+ return new SdkHttpException($request, $response);
+}
+
+it('maps SDK body.data groups to {id, name} pairs', function (): void {
+ $client = makeMailerLiteClientForGroups([
+ ['id' => '1001', 'name' => 'Newsletter'],
+ ['id' => '1002', 'name' => 'Customers'],
+ ]);
+ $service = new MailerLiteService(fn () => $client);
+
+ $result = $service->listGroups('valid-key');
+
+ expect($result)->toBe([
+ ['id' => '1001', 'name' => 'Newsletter'],
+ ['id' => '1002', 'name' => 'Customers'],
+ ]);
+});
+
+it('returns empty array when account has zero groups', function (): void {
+ $client = makeMailerLiteClientForGroups([]);
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->listGroups('valid-key'))->toBe([]);
+});
+
+it('skips malformed rows missing id or name', function (): void {
+ $client = makeMailerLiteClientForGroups([
+ ['id' => '1001'], // missing name
+ ['name' => 'Orphan'], // missing id
+ ['id' => '1003', 'name' => 'Good'],
+ ]);
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->listGroups('valid-key'))->toBe([
+ ['id' => '1003', 'name' => 'Good'],
+ ]);
+});
+
+it('re-throws SDK validation exception as addon MailerLiteValidationException', function (): void {
+ $request = Mockery::mock(RequestInterface::class);
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->shouldReceive('getContents')->andReturn('{"message":"invalid key"}');
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn(422);
+ $response->shouldReceive('getBody')->andReturn($stream);
+ $sdkException = new SdkValidationException($request, $response);
+
+ $client = Mockery::mock();
+ $client->groups = Mockery::mock();
+ $client->groups->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listGroups('bad-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+// UAT-02 regression — D-09 amended: 4xx HTTP responses are PERMANENT failures.
+// listGroups() is called by the MailerLiteGroupFieldtype during CP form-edit
+// rendering; surfacing a 401 as Validation lets the fieldtype show an
+// actionable error rather than a transient "service unreachable" copy.
+
+it('UAT-02: re-throws SDK 401 from listGroups as addon MailerLiteValidationException', function (): void {
+ $sdkException = makeGroupsSdkHttpException(401, 'Unauthorized');
+
+ $client = Mockery::mock();
+ $client->groups = Mockery::mock();
+ $client->groups->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listGroups('invalid-key-xxx');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(401);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: re-throws SDK 403 from listGroups as addon MailerLiteValidationException', function (): void {
+ $sdkException = makeGroupsSdkHttpException(403, 'Forbidden');
+
+ $client = Mockery::mock();
+ $client->groups = Mockery::mock();
+ $client->groups->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listGroups('restricted-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(403);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('re-throws SDK 503 from listGroups as addon MailerLiteHttpException (transient — retry)', function (): void {
+ $sdkException = makeGroupsSdkHttpException(503, 'Service Unavailable');
+
+ $client = Mockery::mock();
+ $client->groups = Mockery::mock();
+ $client->groups->shouldReceive('get')->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->listGroups('any-key');
+ expect(false)->toBeTrue('Expected MailerLiteHttpException');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(503);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
diff --git a/tests/MailerLite/MailerLiteServiceTest.php b/tests/MailerLite/MailerLiteServiceTest.php
new file mode 100644
index 0000000..a6130a3
--- /dev/null
+++ b/tests/MailerLite/MailerLiteServiceTest.php
@@ -0,0 +1,290 @@
+shouldReceive('getRequestTarget')->andReturn('/api/subscribers');
+ $request->shouldReceive('getMethod')->andReturn('POST');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn($statusCode);
+ $response->shouldReceive('getReasonPhrase')->andReturn($reasonPhrase);
+
+ return new SdkHttpException($request, $response);
+}
+
+it('exposes upsertSubscriber and a constructor with optional clientFactory test seam (WR-01)', function (): void {
+ $reflection = new ReflectionClass(MailerLiteService::class);
+
+ expect($reflection->isFinal())->toBeTrue();
+
+ // WR-01 — constructor now exists with a single optional ?Closure parameter
+ // (test seam). Production code resolves via the container without args.
+ $constructor = $reflection->getConstructor();
+ expect($constructor)->not->toBeNull();
+ $params = $constructor->getParameters();
+ expect($params)->toHaveCount(1);
+ expect($params[0]->getName())->toBe('clientFactory');
+ expect($params[0]->isOptional())->toBeTrue();
+
+ $publicMethods = array_filter(
+ $reflection->getMethods(ReflectionMethod::IS_PUBLIC),
+ fn (ReflectionMethod $m): bool => ! $m->isConstructor(),
+ );
+ $names = array_map(fn (ReflectionMethod $m): string => $m->getName(), $publicMethods);
+ // Phase 3 Plan 01 adds ping(), listFields(), listGroups() to the contract.
+ expect(array_values($names))->toBe(['upsertSubscriber', 'ping', 'listFields', 'listGroups']);
+});
+
+it('resolves the interface as a singleton bound to the concrete class (D-20 + checker I-001)', function (): void {
+ // Plan 02-01 binds singleton(MailerLiteServiceInterface::class, MailerLiteService::class).
+ // The interface is what Plan 04's Job type-hints; the concrete class cannot be
+ // mocked (final). Repeat resolution returns the same instance, and that instance
+ // is the concrete MailerLiteService.
+ $first = app(MailerLiteServiceInterface::class);
+ $second = app(MailerLiteServiceInterface::class);
+
+ expect($second)->toBe($first);
+ expect($first)->toBeInstanceOf(MailerLiteService::class);
+});
+
+it('declares the locked upsertSubscriber signature: (string, string, array, array, ?string): array', function (): void {
+ $method = new ReflectionMethod(MailerLiteService::class, 'upsertSubscriber');
+ $params = $method->getParameters();
+
+ expect($params)->toHaveCount(5);
+ expect($params[0]->getName())->toBe('apiKey');
+ expect((string) $params[0]->getType())->toBe('string');
+ expect($params[1]->getName())->toBe('email');
+ expect((string) $params[1]->getType())->toBe('string');
+ expect($params[2]->getName())->toBe('fields');
+ expect((string) $params[2]->getType())->toBe('array');
+ expect($params[3]->getName())->toBe('groupIds');
+ expect((string) $params[3]->getType())->toBe('array');
+ expect($params[4]->getName())->toBe('ipAddress');
+ expect((string) $params[4]->getType())->toBe('?string');
+ expect((string) $method->getReturnType())->toBe('array');
+});
+
+// WR-01 — behavior tests via the clientFactory test seam. Without these, only
+// reflection covered the Service and a typo like `$client->subscribes->create`
+// would ship green. The factory closure receives the apiKey and returns an
+// object whose `->subscribers->create($payload)` is the boundary under test.
+
+it('WR-01: calls SDK subscribers->create with email/fields/groups/ip_address payload (SYNC-06)', function (): void {
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')
+ ->once()
+ ->with([
+ 'email' => 'jane@example.com',
+ 'fields' => ['name' => 'Jane'],
+ 'groups' => ['g-1'],
+ 'ip_address' => '127.0.0.1',
+ ])
+ ->andReturn(['status_code' => 200, 'body' => ['data' => ['id' => 'sub-x']]]);
+
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ $result = $service->upsertSubscriber(
+ 'k_live_apikey',
+ 'jane@example.com',
+ ['name' => 'Jane'],
+ ['g-1'],
+ '127.0.0.1',
+ );
+
+ expect($result)->toBe(['status_code' => 200, 'body' => ['data' => ['id' => 'sub-x']]]);
+});
+
+it('WR-01: omits empty fields, empty groups, and null ip_address from the SDK payload', function (): void {
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')
+ ->once()
+ ->with(['email' => 'a@b.com']) // only email — nothing else
+ ->andReturn(['status_code' => 200]);
+
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ $service->upsertSubscriber('k', 'a@b.com', [], [], null);
+});
+
+it('WR-01: passes the apiKey through to the client factory (D-11 stateless)', function (): void {
+ $captured = null;
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andReturn(['status_code' => 200]);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(function (string $apiKey) use (&$captured, $client): object {
+ $captured = $apiKey;
+
+ return $client;
+ });
+
+ $service->upsertSubscriber('k_live_xyz', 'a@b.com', [], [], null);
+
+ expect($captured)->toBe('k_live_xyz');
+});
+
+it('WR-01: rethrows SDK validation exception as addon validation exception preserving previous (D-12)', function (): void {
+ // Build a real SDK validation exception (PSR-7 wired — the SDK constructor
+ // takes RequestInterface + ResponseInterface, not a raw message).
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->allows('getContents')->andReturn('{"message":"The email field must be a valid email address."}');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->allows('getBody')->andReturn($stream);
+ $response->allows('getStatusCode')->andReturn(422);
+
+ $request = Mockery::mock(RequestInterface::class);
+
+ $sdkException = new SdkValidationException($request, $response);
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andThrow($sdkException);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ try {
+ $service->upsertSubscriber('k', 'bogus', [], [], null);
+ expect(false)->toBeTrue('Expected MailerLiteValidationException to be thrown');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(422);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('WR-01: rethrows SDK http exception (5xx) as addon http exception preserving previous (D-12)', function (): void {
+ $sdkException = makeSubscribersSdkHttpException(503, 'Service Unavailable');
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andThrow($sdkException);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ try {
+ $service->upsertSubscriber('k', 'a@b.com', [], [], null);
+ expect(false)->toBeTrue('Expected MailerLiteHttpException to be thrown');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(503);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+// UAT-02 regression — D-09 amended: 4xx HTTP responses from upsertSubscriber()
+// are now classified as MailerLiteValidationException (permanent — no retry)
+// rather than MailerLiteHttpException (transient — retry per backoff).
+//
+// Concrete behaviour change at the Job boundary:
+// - PRE: 401 from upsertSubscriber -> SyncSubscriberJob.handle() catches
+// MailerLiteHttpException -> result='transient_failure' -> Laravel
+// retries 3 times across [60, 300, 900]s = 18 minutes. None succeed.
+// - POST: 401 from upsertSubscriber -> SyncSubscriberJob.handle() catches
+// MailerLiteValidationException -> result='permanent_failure' ->
+// $this->fail($e) -> NO retries. Operator sees the failure once
+// in the activity log; debugging starts immediately.
+//
+// The semantic correctness: a 4xx means MailerLite refused this exact request.
+// Repeating it with the same key/payload will produce the same 4xx. Retrying
+// is wasted queue capacity and obscures the real problem (bad credentials,
+// missing resource, etc.).
+
+it('UAT-02: rethrows SDK 401 from upsertSubscriber as addon MailerLiteValidationException (permanent — no retry)', function (): void {
+ $sdkException = makeSubscribersSdkHttpException(401, 'Unauthorized');
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andThrow($sdkException);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ try {
+ $service->upsertSubscriber('invalid-key', 'a@b.com', [], [], null);
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(401);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: rethrows SDK 403 from upsertSubscriber as addon MailerLiteValidationException (permanent)', function (): void {
+ $sdkException = makeSubscribersSdkHttpException(403, 'Forbidden');
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andThrow($sdkException);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ try {
+ $service->upsertSubscriber('restricted-key', 'a@b.com', [], [], null);
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(403);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: rethrows SDK 500 from upsertSubscriber as addon MailerLiteHttpException (transient — retry)', function (): void {
+ $sdkException = makeSubscribersSdkHttpException(500, 'Internal Server Error');
+
+ $subscribersEndpoint = Mockery::mock();
+ $subscribersEndpoint->shouldReceive('create')->andThrow($sdkException);
+ $client = new stdClass;
+ $client->subscribers = $subscribersEndpoint;
+
+ $service = new MailerLiteService(fn (string $apiKey): object => $client);
+
+ try {
+ $service->upsertSubscriber('k', 'a@b.com', [], [], null);
+ expect(false)->toBeTrue('Expected MailerLiteHttpException');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(500);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('WR-01: catch-order is Validation BEFORE Http (Validation is sibling, not subclass)', function (): void {
+ // SdkValidationException is a sibling of SdkHttpException (both extend
+ // MailerLite\Exceptions\MailerLiteException). If the catch order were
+ // reversed (Http first), Validation would never bubble to Validation.
+ // We rely on this by asserting the addon validation exception is caught
+ // when the SDK throws SdkValidationException — already covered above —
+ // and we additionally assert the type lineage here.
+
+ expect(is_subclass_of(SdkValidationException::class, SdkHttpException::class))->toBeFalse();
+ expect(is_subclass_of(SdkHttpException::class, SdkValidationException::class))->toBeFalse();
+});
diff --git a/tests/MailerLite/PingTest.php b/tests/MailerLite/PingTest.php
new file mode 100644
index 0000000..7135135
--- /dev/null
+++ b/tests/MailerLite/PingTest.php
@@ -0,0 +1,214 @@
+getStatusCode() to set
+ * its own ::getCode() — i.e., $sdkException->getCode() == $statusCode.
+ */
+function makeSdkHttpExceptionWithStatus(int $statusCode, string $reasonPhrase = 'Error'): SdkHttpException
+{
+ $request = Mockery::mock(RequestInterface::class);
+ $request->shouldReceive('getRequestTarget')->andReturn('/api/fields');
+ $request->shouldReceive('getMethod')->andReturn('GET');
+
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn($statusCode);
+ $response->shouldReceive('getReasonPhrase')->andReturn($reasonPhrase);
+
+ return new SdkHttpException($request, $response);
+}
+
+it('returns true on 2xx response from $client->fields->get([])', function (): void {
+ $sdkResponse = ['status_code' => 200, 'headers' => [], 'body' => ['data' => []]];
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')
+ ->once()
+ ->with([])
+ ->andReturn($sdkResponse);
+
+ $service = new MailerLiteService(fn (string $apiKey) => $client);
+
+ expect($service->ping('valid-key'))->toBeTrue();
+});
+
+it('returns true even when account has zero custom fields (SDK body.data empty)', function (): void {
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->with([])->andReturn(['body' => ['data' => []]]);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ expect($service->ping('valid-key'))->toBeTrue();
+});
+
+it('re-throws SDK validation exception as addon MailerLiteValidationException preserving previous', function (): void {
+ $request = Mockery::mock(RequestInterface::class);
+ $stream = Mockery::mock(StreamInterface::class);
+ $stream->shouldReceive('getContents')->andReturn('{"message":"invalid key"}');
+ $response = Mockery::mock(ResponseInterface::class);
+ $response->shouldReceive('getStatusCode')->andReturn(422);
+ $response->shouldReceive('getBody')->andReturn($stream);
+ $sdkException = new SdkValidationException($request, $response);
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('bad-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(422);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+// UAT-02 regression — D-09 amended: 4xx HTTP responses are PERMANENT failures
+// (re-classified from MailerLiteHttpException to MailerLiteValidationException).
+// MailerLite returns 401 for invalid bearer tokens, 403 for forbidden actions,
+// 400 for malformed requests — none of these will succeed if retried with the
+// same input, so the service surfaces them as Validation (permanent) so the CP
+// test-connection UI shows "Invalid API key" copy and the SyncSubscriberJob
+// classifies them as permanent_failure (no retry).
+
+it('UAT-02: re-throws SDK 401 as addon MailerLiteValidationException (invalid API key — permanent)', function (): void {
+ $sdkException = makeSdkHttpExceptionWithStatus(401, 'Unauthorized');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('invalid-key-xxx');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(401);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: re-throws SDK 403 as addon MailerLiteValidationException (forbidden — permanent)', function (): void {
+ $sdkException = makeSdkHttpExceptionWithStatus(403, 'Forbidden');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('restricted-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(403);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: re-throws SDK 400 as addon MailerLiteValidationException (bad request — permanent)', function (): void {
+ $sdkException = makeSdkHttpExceptionWithStatus(400, 'Bad Request');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('any-key');
+ expect(false)->toBeTrue('Expected MailerLiteValidationException');
+ } catch (MailerLiteValidationException $e) {
+ expect($e->getCode())->toBe(400);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('re-throws SDK 503 as addon MailerLiteHttpException (transient — retry)', function (): void {
+ $sdkException = makeSdkHttpExceptionWithStatus(503, 'Service Unavailable');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('any-key');
+ expect(false)->toBeTrue('Expected MailerLiteHttpException');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(503);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('UAT-02: re-throws SDK 500 as addon MailerLiteHttpException (transient — retry)', function (): void {
+ $sdkException = makeSdkHttpExceptionWithStatus(500, 'Internal Server Error');
+
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->once()->andThrow($sdkException);
+
+ $service = new MailerLiteService(fn () => $client);
+
+ try {
+ $service->ping('any-key');
+ expect(false)->toBeTrue('Expected MailerLiteHttpException');
+ } catch (MailerLiteHttpException $e) {
+ expect($e->getCode())->toBe(500);
+ expect($e->getPrevious())->toBe($sdkException);
+ }
+});
+
+it('constructs a fresh SDK client per call (D-11 statelessness)', function (): void {
+ $callCount = 0;
+ $factory = function (string $apiKey) use (&$callCount) {
+ $callCount++;
+ $client = Mockery::mock();
+ $client->fields = Mockery::mock();
+ $client->fields->shouldReceive('get')->andReturn(['body' => ['data' => []]]);
+
+ return $client;
+ };
+
+ $service = new MailerLiteService($factory);
+
+ $service->ping('key-A');
+ $service->ping('key-B');
+
+ expect($callCount)->toBe(2);
+});
+
+it('does NOT delegate to subscribers->create (Pitfall 5 — would create real subscribers)', function (): void {
+ $source = file_get_contents(
+ __DIR__.'/../../src/MailerLite/MailerLiteService.php'
+ );
+ $pingStart = strpos($source, 'public function ping(');
+ $pingEnd = strpos($source, 'public function ', $pingStart + 1);
+ if ($pingEnd === false) {
+ $pingEnd = strlen($source);
+ }
+ $pingBody = substr($source, $pingStart, $pingEnd - $pingStart);
+
+ expect($pingBody)->not->toContain('subscribers->create');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index c5b9257..eb7c375 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -1,5 +1,154 @@
in(
+ 'Activity',
+ 'CP',
+ 'Fieldtypes',
+ 'Forms',
+ 'Listeners',
+ 'MailerLite',
+ 'Settings',
+ 'Storage',
+ 'Sync',
+);
+
+if (! function_exists('makeFakeForm')) {
+ /**
+ * Build a Mockery FormContract mock whose `submissions()` returns the given fakes
+ * and whose `submission($id)` resolves the matching fake by id.
+ *
+ * @param array $submissions
+ */
+ function makeFakeForm(string $handle, array $submissions = [], ?string $title = null): Mockery\MockInterface
+ {
+ $form = Mockery::mock(FormContract::class);
+ $form->shouldReceive('handle')->andReturn($handle);
+ $form->shouldReceive('title')->andReturn($title ?? ucfirst($handle));
+ $form->shouldReceive('store')->andReturn(true);
+ $form->shouldReceive('submissions')->andReturn(new Collection($submissions));
+ $form->shouldReceive('submission')->andReturnUsing(function ($id) use ($submissions): ?FakeSubmission {
+ foreach ($submissions as $fake) {
+ if ((string) $fake->id() === (string) $id) {
+ return $fake;
+ }
+ }
+
+ return null;
+ });
+
+ // Back-ref so $submission->form()->handle() works downstream.
+ foreach ($submissions as $fake) {
+ $fake->form($form);
+ }
+
+ return $form;
+ }
+}
+
+if (! function_exists('bindFormFacade')) {
+ /**
+ * Wire the Form facade so:
+ * Form::find($handle) → form mock matching that handle (or null)
+ * Form::all() → Collection of all form mocks
+ *
+ * Pass keyed by handle, e.g. ['contact' => $form, 'newsletter' => $form2].
+ *
+ * @param array $forms
+ */
+ function bindFormFacade(array $forms): void
+ {
+ Form::shouldReceive('find')->andReturnUsing(function ($handle) use ($forms): ?Mockery\MockInterface {
+ return $forms[(string) $handle] ?? null;
+ });
+
+ Form::shouldReceive('all')->andReturn(new Collection(array_values($forms)));
+ }
+}
+
+if (! function_exists('seedSubmissionWithSync')) {
+ /**
+ * Build a FakeSubmission carrying a `mailerlite_sync.attempts[]` block shaped
+ * exactly as the runtime ActivityLogger would write it. Returns metadata
+ * including the read-side ActivityLogEntry DTO for the seeded attempt.
+ *
+ * Caller still needs to wire Form::find() / Form::all() via {@see bindFormFacade()}
+ * if the test exercises read-side paths.
+ *
+ * @param array{
+ * form_handle?: string,
+ * submission_id?: string|null,
+ * status?: string,
+ * payload?: array,
+ * error_message?: string|null,
+ * } $attrs
+ * @return array{
+ * attempt_id: string,
+ * submission_id: string,
+ * form_handle: string,
+ * submission: FakeSubmission,
+ * entry: ActivityLogEntry,
+ * }
+ */
+ function seedSubmissionWithSync(array $attrs = [], ?Carbon $createdAt = null): array
+ {
+ $formHandle = (string) ($attrs['form_handle'] ?? 'contact');
+ $submissionId = (string) (array_key_exists('submission_id', $attrs) && is_string($attrs['submission_id'])
+ ? $attrs['submission_id']
+ : 'sub-'.Str::uuid()->toString());
+ $status = (string) ($attrs['status'] ?? 'success');
+ $payload = isset($attrs['payload']) && is_array($attrs['payload']) ? $attrs['payload'] : [];
+ $errorMessage = array_key_exists('error_message', $attrs)
+ ? (is_string($attrs['error_message']) ? $attrs['error_message'] : null)
+ : null;
+
+ $attemptId = (string) Str::uuid();
+ $attemptedAt = ($createdAt ?? Carbon::now())->toIso8601String();
+
+ $attempt = [
+ 'id' => $attemptId,
+ 'attempted_at' => $attemptedAt,
+ 'status' => $status,
+ 'payload' => $payload,
+ 'error_message' => $errorMessage,
+ ];
+
+ $form = Mockery::mock(FormContract::class);
+ $form->shouldReceive('handle')->andReturn($formHandle);
+ $form->shouldReceive('title')->andReturn(ucfirst($formHandle));
+
+ $submission = new FakeSubmission(
+ id: $submissionId,
+ form: $form,
+ data: ['mailerlite_sync' => ['attempts' => [$attempt]]],
+ );
+
+ $entry = ActivityLogEntry::fromAttempt(
+ attempt: $attempt,
+ formHandle: $formHandle,
+ submissionId: $submissionId,
+ );
+
+ if ($entry === null) {
+ throw new RuntimeException('seedSubmissionWithSync failed to project ActivityLogEntry');
+ }
-uses(TestCase::class)->in(__DIR__);
+ return [
+ 'attempt_id' => $attemptId,
+ 'submission_id' => $submissionId,
+ 'form_handle' => $formHandle,
+ 'submission' => $submission,
+ 'entry' => $entry,
+ ];
+ }
+}
diff --git a/tests/Storage/AddonYamlDriverTest.php b/tests/Storage/AddonYamlDriverTest.php
new file mode 100644
index 0000000..ecd3aa5
--- /dev/null
+++ b/tests/Storage/AddonYamlDriverTest.php
@@ -0,0 +1,122 @@
+settings()->set('api_key', null)->set('forms', null)->save();
+});
+
+it('returns empty string when no API key has been saved', function () {
+ $driver = new AddonYamlDriver;
+
+ expect($driver->getApiKey())->toBe('');
+});
+
+it('returns empty array for unknown form handle', function () {
+ $driver = new AddonYamlDriver;
+
+ expect($driver->getFormConfig('nonexistent'))->toBe([]);
+ expect($driver->allFormConfigs())->toBe([]);
+});
+
+it('saves API key as plaintext and reads it back unchanged', function () {
+ $driver = new AddonYamlDriver;
+
+ $driver->saveApiKey('ml-secret-12345');
+
+ // Round-trip via interface returns plaintext.
+ expect($driver->getApiKey())->toBe('ml-secret-12345');
+
+ // Compute the YAML path the same way the driver does (NOT hardcoded — Pitfall 5).
+ $path = Addon::get('concept7/statamic-mailerlite')->settings()->path();
+ $raw = YAML::file($path)->parse();
+
+ // The on-disk YAML stores the key as-is — Statamic's native addon settings UI
+ // writes plaintext too, so the driver mirrors that behaviour for round-trip
+ // consistency. Host operators are expected to lock down resources/addons/
+ // storage permissions accordingly.
+ expect($raw['api_key'])->toBe('ml-secret-12345');
+});
+
+it('round-trips form config via the same handle', function () {
+ // Covers STOR-05 + success criterion #2 + D-02 typed array shape.
+ $config = [
+ 'enabled' => true,
+ 'groups' => ['group-123', 'group-456'],
+ 'fields' => [
+ ['form_field' => 'email', 'mailerlite_field' => 'email'],
+ ['form_field' => 'name', 'mailerlite_field' => 'name'],
+ ],
+ ];
+
+ $driver = new AddonYamlDriver;
+ $driver->saveFormConfig('contact', $config);
+
+ expect($driver->getFormConfig('contact'))->toBe($config);
+ expect($driver->getFormConfig('newsletter'))->toBe([]); // unrelated handle stays empty
+});
+
+it('returns lenient defaults from read methods when Addon::get returns null (D-12)', function () {
+ // Covers WR-01: read path must never throw if the addon manifest hasn't been
+ // loaded yet (theoretical CP boot-order edge). saveApiKey/saveFormConfig stay
+ // loud per D-13 — exercised by the matching write-path expectations below.
+ Addon::shouldReceive('get')->andReturn(null);
+
+ $driver = new AddonYamlDriver;
+
+ expect($driver->getApiKey())->toBe('');
+ expect($driver->getFormConfig('any'))->toBe([]);
+ expect($driver->allFormConfigs())->toBe([]);
+});
+
+it('throws MailerLiteStorageException from write methods when Addon::get returns null (D-13)', function () {
+ // D-13 loud-write contract: writes must surface a clear error when the
+ // manifest is unreachable instead of silently swallowing the call.
+ Addon::shouldReceive('get')->andReturn(null);
+
+ $driver = new AddonYamlDriver;
+
+ try {
+ $driver->saveApiKey('foo');
+ $this->fail('Expected MailerLiteStorageException for saveApiKey');
+ } catch (MailerLiteStorageException $e) {
+ expect($e->getMessage())->toContain('addon manifest is not loaded');
+ }
+
+ try {
+ $driver->saveFormConfig('contact', ['enabled' => true]);
+ $this->fail('Expected MailerLiteStorageException for saveFormConfig');
+ } catch (MailerLiteStorageException $e) {
+ expect($e->getMessage())->toContain('addon manifest is not loaded');
+ expect($e->getMessage())->toContain('contact');
+ }
+});
+
+it('keeps multiple form configs separately keyed by handle', function () {
+ $contactConfig = [
+ 'enabled' => true,
+ 'groups' => ['contact-group'],
+ 'fields' => [['form_field' => 'email', 'mailerlite_field' => 'email']],
+ ];
+ $newsletterConfig = [
+ 'enabled' => false,
+ 'groups' => ['newsletter-group'],
+ 'fields' => [['form_field' => 'email', 'mailerlite_field' => 'email']],
+ ];
+
+ $driver = new AddonYamlDriver;
+ $driver->saveFormConfig('contact', $contactConfig);
+ $driver->saveFormConfig('newsletter', $newsletterConfig);
+
+ expect($driver->allFormConfigs())->toHaveKeys(['contact', 'newsletter']);
+ expect($driver->getFormConfig('contact'))->toBe($contactConfig);
+ expect($driver->getFormConfig('newsletter'))->toBe($newsletterConfig);
+});
diff --git a/tests/Storage/ContainerBindingTest.php b/tests/Storage/ContainerBindingTest.php
new file mode 100644
index 0000000..0e7137a
--- /dev/null
+++ b/tests/Storage/ContainerBindingTest.php
@@ -0,0 +1,76 @@
+toBeInstanceOf(AddonYamlDriver::class);
+});
+
+it('returns the same instance within a single request', function () {
+ // Covers D-10 scoped() semantics (first half of ROADMAP success criterion #5).
+ // Two consecutive resolutions in the same test method (== same request lifecycle)
+ // MUST return the same object identity. A singleton() binding would also pass
+ // this test — the next test (forgetScopedInstances) is what proves it's scoped()
+ // and not singleton().
+ $first = app(StorageDriverInterface::class);
+ $second = app(StorageDriverInterface::class);
+
+ expect($second)->toBe($first);
+});
+
+it('returns a fresh instance after forgetScopedInstances', function () {
+ // Covers STOR-03 + ROADMAP success criterion #5 (second half) +
+ // T-TENANT-LEAK-VIA-SINGLETON mitigation evidence.
+ //
+ // forgetScopedInstances() is what Laravel's kernel calls between HTTP requests
+ // (in Octane) and between queued jobs — simulating a new request/job lifecycle.
+ // A singleton() binding would FAIL this test (singleton instances persist
+ // across forgetScopedInstances). The fact that this test passes is the
+ // automated proof that the binding is scoped(), not singleton().
+ $first = app(StorageDriverInterface::class);
+
+ $this->app->forgetScopedInstances();
+
+ $second = app(StorageDriverInterface::class);
+
+ expect($second)->not->toBe($first);
+ // Both instances are still AddonYamlDriver — the lifecycle reset doesn't
+ // change the resolved class, only the cached instance.
+ expect($second)->toBeInstanceOf(AddonYamlDriver::class);
+});
+
+it('does not register a Facade for the storage driver', function () {
+ // T-FACADE-CACHE-LEAK mitigation — RESEARCH §Pitfall 4 + laravel/framework#43151.
+ //
+ // A Facade-style accessor (e.g. Concept7\StatamicMailerLite\Facades\Storage)
+ // would cache the resolved instance statically on the Facade class, bypassing
+ // forgetScopedInstances() and defeating scoped() in queue contexts. The
+ // simplest defense is structural: no Facade exists.
+ //
+ // This test asserts:
+ // 1. No class named ...\Facades\Storage exists in the addon namespace.
+ // 2. No registered alias in the container resolves to StorageDriverInterface.
+ expect(class_exists('Concept7\\StatamicMailerLite\\Facades\\Storage'))->toBeFalse();
+
+ // Inspect the container's alias registry. Laravel's Container keeps a
+ // protected $abstractAliases array keyed by abstract — the array of
+ // short-name roots a Facade could resolve through. We reflect on it to
+ // assert no alias points to StorageDriverInterface — equivalent to the
+ // (non-existent) getAliases() helper the plan called for.
+ $reflection = new ReflectionClass($this->app);
+ $property = $reflection->getProperty('abstractAliases');
+ $property->setAccessible(true);
+ /** @var array> $abstractAliases */
+ $abstractAliases = $property->getValue($this->app);
+ $aliases = $abstractAliases[StorageDriverInterface::class] ?? [];
+ expect($aliases)->toBe([]);
+});
diff --git a/tests/Storage/InterfaceContractTest.php b/tests/Storage/InterfaceContractTest.php
new file mode 100644
index 0000000..b59098d
--- /dev/null
+++ b/tests/Storage/InterfaceContractTest.php
@@ -0,0 +1,45 @@
+isInterface())->toBeTrue();
+
+ $methods = collect($reflection->getMethods())->keyBy->getName();
+
+ expect($methods)->toHaveKeys([
+ 'getApiKey',
+ 'saveApiKey',
+ 'getFormConfig',
+ 'saveFormConfig',
+ 'allFormConfigs',
+ ]);
+
+ // Locked return types per D-12 + D-13.
+ expect((string) $methods['getApiKey']->getReturnType())->toBe('string');
+ expect((string) $methods['saveApiKey']->getReturnType())->toBe('void');
+ expect((string) $methods['getFormConfig']->getReturnType())->toBe('array');
+ expect((string) $methods['saveFormConfig']->getReturnType())->toBe('void');
+ expect((string) $methods['allFormConfigs']->getReturnType())->toBe('array');
+
+ // Locked parameter shapes (count + name + type) — additional STOR-06 hardening.
+ expect($methods['saveApiKey']->getParameters())->toHaveCount(1);
+ expect($methods['saveApiKey']->getParameters()[0]->getName())->toBe('key');
+ expect((string) $methods['saveApiKey']->getParameters()[0]->getType())->toBe('string');
+
+ expect($methods['getFormConfig']->getParameters())->toHaveCount(1);
+ expect($methods['getFormConfig']->getParameters()[0]->getName())->toBe('handle');
+ expect((string) $methods['getFormConfig']->getParameters()[0]->getType())->toBe('string');
+
+ expect($methods['saveFormConfig']->getParameters())->toHaveCount(2);
+ expect($methods['saveFormConfig']->getParameters()[0]->getName())->toBe('handle');
+ expect($methods['saveFormConfig']->getParameters()[1]->getName())->toBe('data');
+});
+
+arch('storage namespace does not leak debugging functions')
+ ->expect('Concept7\StatamicMailerLite\Storage')
+ ->not->toUse(['dd', 'dump', 'ray', 'var_dump']);
diff --git a/tests/Sync/Fixtures/TestableSyncSubscriberJob.php b/tests/Sync/Fixtures/TestableSyncSubscriberJob.php
new file mode 100644
index 0000000..b3b9d9b
--- /dev/null
+++ b/tests/Sync/Fixtures/TestableSyncSubscriberJob.php
@@ -0,0 +1,29 @@
+fail() calls
+ * without requiring a real queue context. Resolves the Mockery+final
+ * incompatibility (checker I-002) — Mockery cannot partial-mock final
+ * classes, so SyncSubscriberJob is non-final and this fixture extends it
+ * for testability.
+ */
+class TestableSyncSubscriberJob extends SyncSubscriberJob
+{
+ public ?Throwable $failedWith = null;
+
+ public function fail($exception = null): void
+ {
+ $this->failedWith = $exception;
+ // Deliberately do NOT call parent::fail() — that requires a real
+ // queue context. Pitfall 1 (fail() returns control normally) means
+ // the rest of handle() including finally still runs, which is
+ // exactly what tests verify.
+ }
+}
diff --git a/tests/Sync/SyncPipelineIntegrationTest.php b/tests/Sync/SyncPipelineIntegrationTest.php
new file mode 100644
index 0000000..92cf7b1
--- /dev/null
+++ b/tests/Sync/SyncPipelineIntegrationTest.php
@@ -0,0 +1,140 @@
+settings()->set('api_key', null)->set('forms', null)->save();
+});
+
+afterEach(function (): void {
+ Mockery::close();
+});
+
+it('registers a listener for Statamic\\Events\\SubmissionCreated via the service provider', function (): void {
+ expect(Event::hasListeners(SubmissionCreated::class))->toBeTrue();
+});
+
+it('registers a listener for Statamic\\Events\\FormSubmitted via the service provider', function (): void {
+ expect(Event::hasListeners(FormSubmitted::class))->toBeTrue();
+});
+
+// WR-07 — assert EXACTLY one listener for each event so a regression that
+// re-introduces explicit Event::listen calls on top of Statamic's
+// AddonServiceProvider auto-discovery (causing each listener to fire TWICE
+// per event — Pitfall documented in StatamicMailerLiteServiceProvider docblock)
+// is caught by the integration test, not silently shipped.
+
+it('WR-07: registers EXACTLY one listener for SubmissionCreated (no duplicate auto-discovery)', function (): void {
+ /** @var Dispatcher $dispatcher */
+ $dispatcher = app('events');
+ $listeners = $dispatcher->getListeners(SubmissionCreated::class);
+
+ expect(count($listeners))->toBe(1);
+});
+
+it('WR-07: registers EXACTLY one listener for FormSubmitted (no duplicate auto-discovery)', function (): void {
+ /** @var Dispatcher $dispatcher */
+ $dispatcher = app('events');
+ $listeners = $dispatcher->getListeners(FormSubmitted::class);
+
+ expect(count($listeners))->toBe(1);
+});
+
+it('dispatches exactly one SyncSubscriberJob when an enabled form fires SubmissionCreated', function (): void {
+ Bus::fake();
+
+ // Seed real form config via the Phase 1 driver (the binding Event::listen
+ // resolves through). Use the container-resolved driver so the provider's
+ // scoped() binding is the path under test.
+ $driver = app(StorageDriverInterface::class);
+ $driver->saveFormConfig('contact', [
+ 'enabled' => true,
+ 'groups' => ['group-1'],
+ 'fields' => [
+ ['form_field' => 'name', 'mailerlite_field' => 'subscriber_name'],
+ ],
+ ]);
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->shouldReceive('id')->andReturn('sub-integration-001');
+
+ // Fire the real event through Laravel's dispatcher — the provider's
+ // Event::listen wiring should route to SubmissionCreatedListener.
+ event(new SubmissionCreated($submission));
+
+ Bus::assertDispatched(SyncSubscriberJob::class, 1);
+ Bus::assertDispatched(SyncSubscriberJob::class, function (SyncSubscriberJob $job): bool {
+ return $job->submissionId === 'sub-integration-001'
+ && $job->formHandle === 'contact';
+ });
+});
+
+it('does NOT dispatch a Job when the form has no MailerLite config (success criterion #5)', function (): void {
+ Bus::fake();
+
+ // Note: no saveFormConfig call — the form has no config seeded.
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('untouched-form');
+ $form->allows('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->allows('id')->andReturn('sub-no-config');
+
+ event(new SubmissionCreated($submission));
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('does NOT dispatch a Job when the form has enabled: false', function (): void {
+ Bus::fake();
+
+ $driver = app(StorageDriverInterface::class);
+ $driver->saveFormConfig('disabled-form', [
+ 'enabled' => false,
+ 'groups' => [],
+ 'fields' => [],
+ ]);
+
+ $form = Mockery::mock(Form::class);
+ $form->shouldReceive('handle')->andReturn('disabled-form');
+ $form->allows('store')->andReturn(true);
+
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('form')->andReturn($form);
+ $submission->allows('id')->andReturn('sub-disabled-int');
+
+ event(new SubmissionCreated($submission));
+
+ Bus::assertNotDispatched(SyncSubscriberJob::class);
+});
+
+it('FormSubmitted listener mutates the submission via set (SYNC-01 end-to-end)', function (): void {
+ $submission = Mockery::mock(Submission::class);
+ $submission->shouldReceive('set')
+ ->with('ip_address', Mockery::any())
+ ->once()
+ ->andReturnSelf();
+
+ // Fire the real event — FormSubmittedListener should be invoked by the dispatcher.
+ event(new FormSubmitted($submission));
+});
diff --git a/tests/Sync/SyncSubscriberJobTest.php b/tests/Sync/SyncSubscriberJobTest.php
new file mode 100644
index 0000000..9bdba71
--- /dev/null
+++ b/tests/Sync/SyncSubscriberJobTest.php
@@ -0,0 +1,733 @@
+shouldReceive('handle')->andReturn($handle);
+ $form->shouldReceive('title')->andReturn(ucfirst($handle));
+ $form->shouldReceive('store')->andReturn(true);
+ // makeSubmission() returns a fresh in-memory Statamic submission — the job's
+ // loadSubmission() uses this for the submissionData (in-memory) path.
+ $form->shouldReceive('makeSubmission')->andReturnUsing(fn (): StatamicSubmission => new StatamicSubmission);
+ // submission($id) returns the FakeSubmission — the ActivityLogger annotates it.
+ $form->shouldReceive('submission')->andReturnUsing(function ($id) use ($fake): ?FakeSubmission {
+ return (string) $fake->id() === (string) $id ? $fake : null;
+ });
+ $form->shouldReceive('submissions')->andReturn(new Collection([$fake]));
+
+ $fake->form($form);
+
+ Form::shouldReceive('find')->andReturnUsing(function ($requested) use ($handle, $form) {
+ return $requested === $handle ? $form : null;
+ });
+ Form::shouldReceive('all')->andReturn(new Collection([$form]));
+
+ return $fake;
+}
+
+afterEach(function (): void {
+ Mockery::close();
+});
+
+arch('SyncSubscriberJob implements ShouldQueue (SYNC-03)')
+ ->expect(SyncSubscriberJob::class)
+ ->toImplement(ShouldQueue::class);
+
+it('declares constructor with exactly four public readonly parameters in the locked order', function (): void {
+ $reflection = new ReflectionClass(SyncSubscriberJob::class);
+ $constructor = $reflection->getConstructor();
+ expect($constructor)->not->toBeNull();
+ $params = $constructor->getParameters();
+
+ expect($params)->toHaveCount(3);
+ expect($params[0]->getName())->toBe('submissionId');
+ expect((string) $params[0]->getType())->toBe('string');
+ expect($params[1]->getName())->toBe('formHandle');
+ expect((string) $params[1]->getType())->toBe('string');
+ expect($params[2]->getName())->toBe('submissionData');
+ expect((string) $params[2]->getType())->toBe('?array');
+
+ foreach ($params as $param) {
+ $property = $reflection->getProperty($param->getName());
+ expect($property->isReadOnly())->toBeTrue();
+ expect($property->isPublic())->toBeTrue();
+ }
+});
+
+it('does NOT carry an apiKey constructor parameter (SYNC-04 — never in payload)', function (): void {
+ $reflection = new ReflectionClass(SyncSubscriberJob::class);
+ $constructor = $reflection->getConstructor();
+ $names = array_map(fn (ReflectionParameter $p): string => $p->getName(), $constructor->getParameters());
+
+ expect($names)->not->toContain('apiKey');
+ expect($names)->not->toContain('api_key');
+ expect($names)->not->toContain('mappings');
+ expect($names)->not->toContain('config');
+});
+
+it('declares retry config — tries=3 and backoff=[60,300,900]', function (): void {
+ $job = new SyncSubscriberJob('s-1', 'contact');
+
+ expect($job->tries)->toBe(3);
+ expect($job->backoff)->toBe([60, 300, 900]);
+});
+
+it('handles success outcome and writes one success attempt onto the submission', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-success', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live_apikey');
+ $driver->shouldReceive('getFormConfig')->with('contact')->andReturn([
+ 'enabled' => true,
+ 'groups' => ['group-1'],
+ 'fields' => [
+ ['form_field' => 'name', 'mailerlite_field' => 'subscriber_name'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k_live_apikey', 'jane@example.com', ['subscriber_name' => 'Jane'], ['group-1'], '203.0.113.7')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-success',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'name' => 'Jane', 'ip_address' => '203.0.113.7'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts)->toHaveCount(1);
+ expect($attempts[0]['status'])->toBe('success');
+});
+
+it('classifies validation exception as permanent_failure and does not retry (SYNC-08 + D-09)', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-invalid', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->andThrow(new MailerLiteValidationException('Email is invalid', 422));
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-invalid',
+ formHandle: 'contact',
+ submissionData: ['email' => 'bogus', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class, 'Email is invalid');
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+ expect($job->failedWith->getMessage())->toBe('Email is invalid');
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts)->toHaveCount(1);
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+ expect($attempts[0]['error_message'])->toBe('Email is invalid');
+});
+
+it('classifies http exception as transient_failure and re-throws so Laravel retries (SYNC-08 + D-09)', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-503', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->andThrow(new MailerLiteHttpException('Service unavailable', 503));
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-503',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteHttpException::class);
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts)->toHaveCount(1);
+ expect($attempts[0]['status'])->toBe('transient_failure');
+ expect($attempts[0]['error_message'])->toBe('Service unavailable');
+});
+
+it('short-circuits on empty API key with permanent_failure attempt (D-08 + Pitfall 1)', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-empty-key', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn(''); // empty
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-empty-key',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class, 'API key not configured');
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts)->toHaveCount(1);
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+ expect($attempts[0]['error_message'])->toBe('API key not configured');
+});
+
+it('filters empty-string field values before calling the service (SYNC-07 + D-14)', function (): void {
+ bindSyncForm('contact', new FakeSubmission('sub-filter', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 'name', 'mailerlite_field' => 'subscriber_name'],
+ ['form_field' => 'phone', 'mailerlite_field' => 'subscriber_phone'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k', 'a@b.com', ['subscriber_name' => 'Jane'], [], '127.0.0.1')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-filter',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'name' => 'Jane', 'phone' => '', 'ip_address' => '127.0.0.1'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
+
+it('forwards groups + ip_address to the service (SYNC-06)', function (): void {
+ bindSyncForm('contact', new FakeSubmission('sub-grp', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => ['g-1', 'g-2'],
+ 'fields' => [],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k', 'jane@example.com', [], ['g-1', 'g-2'], '203.0.113.7')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-grp',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '203.0.113.7'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
+
+it('uses Phase 1 D-02 form-config shape — translates form_field => mailerlite_field (D-15)', function (): void {
+ bindSyncForm('contact', new FakeSubmission('sub-d15', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 'name', 'mailerlite_field' => 'subscriber_name'],
+ ['form_field' => 'company', 'mailerlite_field' => 'subscriber_company'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with(
+ 'k',
+ 'a@b.com',
+ ['subscriber_name' => 'Jane', 'subscriber_company' => 'Concept7'],
+ [],
+ '127.0.0.1',
+ )
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-d15',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'name' => 'Jane', 'company' => 'Concept7', 'ip_address' => '127.0.0.1'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
+
+it('Q1 Option A: when submissionData is non-null, does not load from disk (store: false path; warn-skip on log)', function (): void {
+ // form.store:false → no on-disk submission for the logger to annotate.
+ // The logger warns and skips; the job's primary outcome (success) is unaffected.
+ $form = Mockery::mock(FormContract::class);
+ $form->shouldReceive('handle')->andReturn('contact');
+ $form->shouldReceive('store')->andReturn(false);
+ $form->shouldReceive('makeSubmission')->andReturnUsing(fn (): StatamicSubmission => new StatamicSubmission);
+ $form->shouldReceive('submission')->andReturn(null); // not on disk
+ $form->shouldReceive('submissions')->andReturn(new Collection);
+ Form::shouldReceive('find')->andReturn($form);
+ Form::shouldReceive('all')->andReturn(new Collection([$form]));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'groups' => [], 'fields' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'in-memory-only',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ // Must not throw — logger handles missing submission gracefully.
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ // No on-disk submission means no annotation; ActivityLogEntry::all() is empty
+ // for this submission_id. D-05 design constraint, documented in § Phase 6 D-05.
+ expect(ActivityLogEntry::all()->where('submission_id', 'in-memory-only')->count())->toBe(0);
+});
+
+it('CR-01: resolves email field name from config[email_field] when set', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-cr01-custom', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'email_field' => 'email_address',
+ 'groups' => [],
+ 'fields' => [],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k', 'jane@example.com', [], [], '127.0.0.1')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-cr01-custom',
+ formHandle: 'contact',
+ submissionData: ['email_address' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('success');
+});
+
+it('CR-01: classifies missing/empty email as permanent_failure with actionable message', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-cr01-missing', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'email_field' => 'email_address',
+ 'groups' => [],
+ 'fields' => [],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-cr01-missing',
+ formHandle: 'contact',
+ submissionData: ['name' => 'Jane', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class);
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+ expect($job->failedWith->getMessage())->toContain('email_address');
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+ expect($attempts[0]['error_message'])->toContain('email_address');
+});
+
+it('CR-02: classifies malformed fields[] entry (missing mailerlite_field) as permanent_failure', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-cr02-malformed', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 'name'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-cr02-malformed',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'name' => 'Jane', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class);
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+ expect($job->failedWith->getMessage())->toContain('Malformed');
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+});
+
+it('CR-02: classifies malformed fields[] entry (non-string form_field) as permanent_failure', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-cr02-nonstring', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 123, 'mailerlite_field' => 'name'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-cr02-nonstring',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class);
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+});
+
+it('CR-02: classifies non-array fields config as permanent_failure', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-cr02-notarray', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => 'not-an-array',
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-cr02-notarray',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class);
+
+ expect($job->failedWith)->toBeInstanceOf(MailerLiteValidationException::class);
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+});
+
+it('CR-01: defaults to email_field=email when config omits the key (backward compat)', function (): void {
+ bindSyncForm('contact', new FakeSubmission('sub-cr01-default', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k', 'jane@example.com', [], [], '127.0.0.1')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-cr01-default',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
+
+it('writes payload through the redactor before persistence (D-18 chain)', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('sub-redact', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 'password', 'mailerlite_field' => 'password'],
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-redact',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'password' => 'leak123', 'ip_address' => '127.0.0.1'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ $attempt = $fake->get('mailerlite_sync')['attempts'][0];
+ expect($attempt['payload'])->not->toHaveKey('password'); // top-level: redacted
+});
+
+it('SYNC-XX: activity-log write failure in finally degrades to Log::warning — never propagates to submitter', function (): void {
+ // Phase 6 D-05 regression: when the logger's $submission->save() throws (disk
+ // full, permissions, host gone), the job's finally-block wrap MUST catch and
+ // degrade to Log::warning. The form submitter must see success.
+ $fake = new FakeSubmission('sub-logfail', Mockery::mock(FormContract::class));
+ $fake->throwOnSave(new RuntimeException('disk full'));
+ bindSyncForm('contact', $fake);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->andReturn(['status_code' => 200]);
+
+ Log::spy();
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'sub-logfail',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ // Must NOT throw.
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ Log::shouldHaveReceived('warning')
+ ->once()
+ ->with(
+ 'MailerLite activity log write failed — submission sync outcome not persisted',
+ Mockery::on(function (array $context): bool {
+ return ($context['form_handle'] ?? '') === 'contact'
+ && ($context['submission_id'] ?? '') === 'sub-logfail'
+ && ($context['sync_status'] ?? '') === 'success'
+ && isset($context['log_error']);
+ }),
+ );
+});
+
+it('SYNC-XX: activity-log write failure during permanent_failure path degrades to Log::warning', function (): void {
+ $fake = new FakeSubmission('sub-stacked', Mockery::mock(FormContract::class));
+ $fake->throwOnSave(new RuntimeException('disk full'));
+ bindSyncForm('contact', $fake);
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'groups' => [],
+ 'fields' => [
+ ['form_field' => 'email', 'mailerlite_field' => ['email']], // malformed
+ ],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ Log::spy();
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'sub-stacked',
+ formHandle: 'contact',
+ submissionData: ['email' => 'jane@example.com', 'ip_address' => '127.0.0.1'],
+ );
+
+ // The validation exception (permanent_failure) IS still re-thrown (WR-04).
+ // The log-write exception must NOT replace or wrap it.
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class, 'Malformed fields[0]');
+
+ Log::shouldHaveReceived('warning')
+ ->once()
+ ->with(
+ 'MailerLite activity log write failed — submission sync outcome not persisted',
+ Mockery::on(function (array $context): bool {
+ return ($context['sync_status'] ?? '') === 'permanent_failure'
+ && isset($context['log_error']);
+ }),
+ );
+});
+
+// ─── Phase 04 CP-05 — Post-mapped retry payload (T-04-RETRY-DOUBLE-MAP) ────────
+
+it('skips mapFields when submissionData is post-mapped — uses fields/groups/email/ip_address directly', function (): void {
+ bindSyncForm('contact', new FakeSubmission('retry-xxx', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k_live');
+ $driver->shouldReceive('getFormConfig')->andReturn([
+ 'enabled' => true,
+ 'fields' => [['form_field' => 'name', 'mailerlite_field' => 'subscriber_name']],
+ 'groups' => ['g_from_config_should_not_be_used'],
+ ]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k_live', 'a@b.com', ['ml_first' => 'Jane'], ['g_stored_1'], '1.2.3.4')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'retry-xxx',
+ formHandle: 'contact',
+ submissionData: [
+ 'email' => 'a@b.com',
+ 'fields' => ['ml_first' => 'Jane'],
+ 'groups' => ['g_stored_1'],
+ 'ip_address' => '1.2.3.4',
+ ],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
+
+it('post-mapped path still writes a success attempt in the finally block', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('retry-yyy', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'fields' => [], 'groups' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')->once()->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'retry-yyy',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'fields' => [], 'groups' => [], 'ip_address' => null],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('success');
+});
+
+it('post-mapped path raises permanent_failure when email key is empty in the stored payload', function (): void {
+ $fake = bindSyncForm('contact', new FakeSubmission('retry-empty-email', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'fields' => [], 'groups' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldNotReceive('upsertSubscriber');
+
+ $job = new TestableSyncSubscriberJob(
+ submissionId: 'retry-empty-email',
+ formHandle: 'contact',
+ submissionData: ['email' => '', 'fields' => [], 'groups' => [], 'ip_address' => null],
+ );
+
+ expect(fn () => $job->handle($driver, $service, app(ActivityLogger::class)))
+ ->toThrow(MailerLiteValidationException::class);
+
+ $attempts = $fake->get('mailerlite_sync')['attempts'];
+ expect($attempts[0]['status'])->toBe('permanent_failure');
+ expect($attempts[0]['error_message'])->toContain('empty email');
+});
+
+it('post-mapped detection requires fields to be an array — string fields fall through to mapFields', function (): void {
+ bindSyncForm('contact', new FakeSubmission('edge-string-fields', Mockery::mock(FormContract::class)));
+
+ $driver = Mockery::mock(StorageDriverInterface::class);
+ $driver->shouldReceive('getApiKey')->andReturn('k');
+ $driver->shouldReceive('getFormConfig')->andReturn(['enabled' => true, 'fields' => [], 'groups' => []]);
+
+ $service = Mockery::mock(MailerLiteServiceInterface::class);
+ $service->shouldReceive('upsertSubscriber')
+ ->once()
+ ->with('k', 'a@b.com', [], [], '1.2.3.4')
+ ->andReturn(['status_code' => 200]);
+
+ $job = new SyncSubscriberJob(
+ submissionId: 'edge-string-fields',
+ formHandle: 'contact',
+ submissionData: ['email' => 'a@b.com', 'fields' => 'this-is-a-string-not-an-array', 'ip_address' => '1.2.3.4'],
+ );
+
+ $job->handle($driver, $service, app(ActivityLogger::class));
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index ba550de..514381e 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -1,33 +1,39 @@
Statamic::class,
- ];
- }
+ parent::getEnvironmentSetUp($app);
- public function getEnvironmentSetUp($app)
- {
- $app['config']->set('database.default', 'testing');
- $app['config']->set('statamic.stache.watcher', false);
- $app['config']->set('statamic.stache.stores', []);
+ $app['config']->set('app.key', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF');
+
+ // Phase 3 / CP HTTP tests — tests create multiple Statamic users
+ // (permitted, super, non-permitted). Statamic's CP CountUsers middleware
+ // throws StatamicProRequiredException when user count > 1 and pro = false.
+ // Enable pro for the test environment so HTTP tests can create multiple users.
+ $app['config']->set('statamic.editions.pro', true);
}
}
diff --git a/tests/__fixtures__/users/admin+guard@example.com.yaml b/tests/__fixtures__/users/admin+guard@example.com.yaml
new file mode 100644
index 0000000..d0360d8
--- /dev/null
+++ b/tests/__fixtures__/users/admin+guard@example.com.yaml
@@ -0,0 +1,4 @@
+name: 'Guard Admin'
+roles:
+ - mailerlite_admin
+id: b6063036-6bae-41cb-93df-45bfa50e32b6
diff --git a/tests/__fixtures__/users/admin+permitted@example.com.yaml b/tests/__fixtures__/users/admin+permitted@example.com.yaml
new file mode 100644
index 0000000..91990d2
--- /dev/null
+++ b/tests/__fixtures__/users/admin+permitted@example.com.yaml
@@ -0,0 +1,4 @@
+name: 'Permitted Admin'
+roles:
+ - mailerlite_admin
+id: 44a59371-c423-4a8a-9b71-dfab40d1b5d9
diff --git a/tests/__fixtures__/users/regular@example.com.yaml b/tests/__fixtures__/users/regular@example.com.yaml
new file mode 100644
index 0000000..3615acf
--- /dev/null
+++ b/tests/__fixtures__/users/regular@example.com.yaml
@@ -0,0 +1,4 @@
+name: Regular
+roles:
+ - cp_only
+id: 58c6e638-0f3b-4c3e-a44d-3cd198ea2f76
diff --git a/tests/__fixtures__/users/super@example.com.yaml b/tests/__fixtures__/users/super@example.com.yaml
new file mode 100644
index 0000000..479ae6c
--- /dev/null
+++ b/tests/__fixtures__/users/super@example.com.yaml
@@ -0,0 +1,3 @@
+name: Super
+super: true
+id: 040efeb2-bf10-45f6-a6ba-f689a5733631
diff --git a/vite.config.js b/vite.config.js
index 02ca98a..ca7112d 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,13 +1,82 @@
import { defineConfig } from 'vite';
-import laravel from 'laravel-vite-plugin';
-import statamic from '@statamic/cms/vite-plugin';
+import vue from '@vitejs/plugin-vue';
+
+/**
+ * Addon CP Vite config.
+ *
+ * The `statamicExternals` plugin (inlined from @statamic/cms/vite-plugin/externals.js)
+ * rewrites `import ... from 'vue'` to consume `window.Vue` at runtime — Statamic's
+ * Vue instance. Without it, our bundle ships its own Vue runtime and reactive state
+ * created by our `ref()` isn't tracked by Statamic's renderer (v-model and updates
+ * silently break — no console error, just "input doesn't update anything").
+ *
+ * The Vue exports list is the public Vue 3 API surface — kept in sync with what
+ * Statamic exposes via window.Vue at vendor/statamic/cms/resources/js/index.js:17.
+ *
+ * Output lands in public/build/. `php artisan vendor:publish --provider=...` copies
+ * it to host's public/vendor/statamic-mailerlite/build/ where Statamic's
+ * AddonServiceProvider::registerVite() expects to find manifest.json.
+ */
+function statamicExternals () {
+ const RESOLVED = '\0vue-external';
+ const VUE_EXPORTS = [
+ 'Comment','EffectScope','Fragment','KeepAlive','ReactiveEffect','Static','Suspense',
+ 'Teleport','Text','Transition','TransitionGroup','VueElement','assertNumber',
+ 'callWithAsyncErrorHandling','callWithErrorHandling','camelize','capitalize',
+ 'cloneVNode','compatUtils','compile','computed','createApp','createBaseVNode',
+ 'createBlock','createCommentVNode','createElementBlock','createElementVNode',
+ 'createHydrationRenderer','createPropsRestProxy','createRenderer','createSSRApp',
+ 'createSlots','createStaticVNode','createTextVNode','createVNode','customRef',
+ 'defineAsyncComponent','defineComponent','defineCustomElement','defineEmits',
+ 'defineExpose','defineModel','defineOptions','defineProps','defineSSRCustomElement',
+ 'defineSlots','devtools','effect','effectScope','getCurrentInstance','getCurrentScope',
+ 'getTransitionRawChildren','guardReactiveProps','h','handleError','hasInjectionContext',
+ 'hydrate','hydrateOnIdle','hydrateOnInteraction','hydrateOnMediaQuery','hydrateOnVisible',
+ 'initCustomFormatter','initDirectivesForSSR','inject','isMemoSame','isProxy','isReactive',
+ 'isReadonly','isRef','isRuntimeOnly','isShallow','isVNode','markRaw','mergeDefaults',
+ 'mergeModels','mergeProps','nextTick','normalizeClass','normalizeProps','normalizeStyle',
+ 'onActivated','onBeforeMount','onBeforeUnmount','onBeforeUpdate','onDeactivated',
+ 'onErrorCaptured','onMounted','onRenderTracked','onRenderTriggered','onScopeDispose',
+ 'onServerPrefetch','onUnmounted','onUpdated','onWatcherCleanup','openBlock','popScopeId',
+ 'provide','proxyRefs','pushScopeId','queuePostFlushCb','reactive','readonly','ref',
+ 'registerRuntimeCompiler','render','renderList','renderSlot','resolveComponent',
+ 'resolveDirective','resolveDynamicComponent','resolveFilter','resolveTransitionHooks',
+ 'setBlockTracking','setDevtoolsHook','setTransitionHooks','shallowReactive','shallowReadonly',
+ 'shallowRef','ssrContextKey','ssrUtils','stop','toDisplayString','toHandlerKey','toHandlers',
+ 'toRaw','toRef','toRefs','toValue','transformVNodeArgs','triggerRef','unref','useAttrs',
+ 'useCssModule','useCssVars','useHost','useId','useModel','useShadowRoot','useSSRContext',
+ 'useSlots','useTemplateRef','useTransitionState','vModelCheckbox','vModelDynamic',
+ 'vModelRadio','vModelSelect','vModelText','vShow','version','warn','watch','watchEffect',
+ 'watchPostEffect','watchSyncEffect','withAsyncContext','withCtx','withDefaults',
+ 'withDirectives','withKeys','withMemo','withModifiers','withScopeId',
+ ];
+
+ return {
+ name: 'statamic-vue-externals',
+ enforce: 'pre',
+ resolveId (id) {
+ if (id === 'vue') return RESOLVED;
+ return null;
+ },
+ load (id) {
+ if (id !== RESOLVED) return null;
+ return `
+ const Vue = window.Vue;
+ export default Vue;
+ export const { ${VUE_EXPORTS.join(', ')} } = Vue;
+ `;
+ },
+ };
+}
export default defineConfig({
- plugins: [
- laravel({
- input: ['resources/js/addon.js'],
- publicDirectory: 'resources/dist',
- }),
- statamic(),
- ],
+ plugins: [statamicExternals(), vue()],
+ publicDir: false,
+ build: {
+ manifest: 'manifest.json',
+ outDir: 'public/build',
+ rollupOptions: {
+ input: 'resources/js/cp/index.js',
+ },
+ },
});