From 4d8978e8b7df2461b7105d81c9b1dc5ff144bb76 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 18 May 2026 14:36:17 +0400 Subject: [PATCH 1/3] dev --- composer.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index af8f27f..af06be9 100755 --- a/composer.json +++ b/composer.json @@ -47,11 +47,11 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-dev", "symfony/twig-bundle": "^6.4", "symfony/webpack-encore-bundle": "^2.2", "symfony/security-bundle": "^6.4", - "tatevikgr/rest-api-client": "dev-main" + "tatevikgr/rest-api-client": "dev-dev" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/package.json b/package.json index a0bd099..7772419 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "devDependencies": { "@babel/core": "^7.27.4", "@babel/preset-env": "^7.27.2", - "@vitejs/plugin-vue": "^5.2.4", "@symfony/webpack-encore": "^5.1.0", "@tailwindcss/postcss": "^4.2.1", + "@vitejs/plugin-vue": "^5.2.4", "@vue/compiler-sfc": "^3.5.16", "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.27", From 20919ed483c30eefebf22546b352adf8e37b3dcd Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 10 Jun 2026 11:45:36 +0000 Subject: [PATCH 2/3] Update openapi.json from web frontend workflow 2026-06-10T11:45:36Z --- openapi.json | 538 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 414 insertions(+), 124 deletions(-) diff --git a/openapi.json b/openapi.json index 722bd8a..a94c1d9 100644 --- a/openapi.json +++ b/openapi.json @@ -4,7 +4,7 @@ "title": "phpList API Documentation", "description": "This is the OpenAPI documentation for phpList API.", "contact": { - "email": "support@phplist.com" + "email": "tatevik@phplist.com" }, "license": { "name": "AGPL-3.0-or-later", @@ -1393,10 +1393,18 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BounceView" - } + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BounceView" + } + }, + "pagination": { + "$ref": "#/components/schemas/CursorPagination" + } + }, + "type": "object" } } } @@ -4715,14 +4723,14 @@ } } }, - "/api/v2/subscribe-pages/{id}": { + "/api/v2/subscribe-pages": { "get": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Get subscribe page", + "summary": "Get subscribe pages list", "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "390db83b1de32e07d2d52b310eb0c1ea", + "operationId": "1a89921fa5f82ce43daf2ca40dc3f954", "parameters": [ { "name": "php-auth-pw", @@ -4734,12 +4742,26 @@ } }, { - "name": "id", - "in": "path", - "description": "Subscribe page ID", - "required": true, + "name": "after_id", + "in": "query", + "description": "Last id (starting from 0)", + "required": false, "schema": { - "type": "integer" + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results per page", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100, + "minimum": 1 } } ], @@ -4749,7 +4771,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubscribePage" + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubscribePage" + } + }, + "pagination": { + "$ref": "#/components/schemas/CursorPagination" + } + }, + "type": "object" } } } @@ -4776,13 +4809,13 @@ } } }, - "put": { + "post": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Update subscribe page", + "summary": "Create subscribe page", "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "a82a0cbe52063758b55279ce7b96657b", + "operationId": "314286c5d8ef80c845f5dfd2d671bad3", "parameters": [ { "name": "php-auth-pw", @@ -4792,15 +4825,6 @@ "schema": { "type": "string" } - }, - { - "name": "id", - "in": "path", - "description": "Subscribe page ID", - "required": true, - "schema": { - "type": "integer" - } } ], "requestBody": { @@ -4810,12 +4834,26 @@ "schema": { "properties": { "title": { - "type": "string", - "nullable": true + "type": "string" }, "active": { "type": "boolean", "nullable": true + }, + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "nullable": true } }, "type": "object" @@ -4824,8 +4862,8 @@ } }, "responses": { - "200": { - "description": "Success", + "201": { + "description": "Created", "content": { "application/json": { "schema": { @@ -4844,25 +4882,27 @@ } } }, - "404": { - "description": "Not Found", + "422": { + "description": "Validation failed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundErrorResponse" + "$ref": "#/components/schemas/ValidationErrorResponse" } } } } } - }, - "delete": { + } + }, + "/api/v2/subscribe-pages/{id}": { + "get": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Delete subscribe page", + "summary": "Get subscribe page", "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "2881b5de1d076caa070f2b9a1c8487fe", + "operationId": "390db83b1de32e07d2d52b310eb0c1ea", "parameters": [ { "name": "php-auth-pw", @@ -4884,8 +4924,15 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribePage" + } + } + } }, "403": { "description": "Failure", @@ -4908,16 +4955,14 @@ } } } - } - }, - "/api/v2/subscribe-pages": { - "post": { + }, + "put": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Create subscribe page", + "summary": "Update subscribe page", "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "314286c5d8ef80c845f5dfd2d671bad3", + "operationId": "a82a0cbe52063758b55279ce7b96657b", "parameters": [ { "name": "php-auth-pw", @@ -4927,6 +4972,15 @@ "schema": { "type": "string" } + }, + { + "name": "id", + "in": "path", + "description": "Subscribe page ID", + "required": true, + "schema": { + "type": "integer" + } } ], "requestBody": { @@ -4936,11 +4990,27 @@ "schema": { "properties": { "title": { - "type": "string" + "type": "string", + "nullable": true }, "active": { "type": "boolean", "nullable": true + }, + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, + "nullable": true } }, "type": "object" @@ -4949,8 +5019,8 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "Success", "content": { "application/json": { "schema": { @@ -4969,27 +5039,25 @@ } } }, - "422": { - "description": "Validation failed", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" } } } } } - } - }, - "/api/v2/subscribe-pages/{id}/data": { - "get": { + }, + "delete": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Get subscribe page data", + "summary": "Delete subscribe page", "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "ffe22f26936614a0e853e7c44e5a0fca", + "operationId": "2881b5de1d076caa070f2b9a1c8487fe", "parameters": [ { "name": "php-auth-pw", @@ -5011,37 +5079,58 @@ } ], "responses": { - "200": { - "description": "Success", + "204": { + "description": "No Content" + }, + "403": { + "description": "Failure", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "data": { - "type": "string", - "nullable": true - } - }, - "type": "object" - } + "$ref": "#/components/schemas/UnauthorizedResponse" } } } }, - "403": { - "description": "Failure", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnauthorizedResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/public/subscribe-pages/{pageId}": { + "get": { + "tags": [ + "subscribe-pages" + ], + "summary": "Get public subscribe page (placeholders replaced with actual values)", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", + "operationId": "0ce8d9a7201bd20911aede0e4b62f479", + "parameters": [ + { + "name": "pageId", + "in": "path", + "description": "Subscribe page ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribePagePublic" } } } @@ -5058,25 +5147,81 @@ } } }, - "put": { + "post": { "tags": [ - "subscriptions" + "subscribe-pages" ], - "summary": "Set subscribe page data item", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", - "operationId": "a71571132947a19d862eac4baf7a98a7", + "summary": "Create subscription", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.Subscribe subscriber to a list from subscribe page.", + "operationId": "106ea367d6b2e0925240532f5cf1087c", "parameters": [ { - "name": "php-auth-pw", - "in": "header", - "description": "Session key obtained from login", + "name": "pageId", + "in": "path", + "description": "Subscribe page ID", "required": true, "schema": { - "type": "string" + "type": "integer" + } + } + ], + "requestBody": { + "description": "", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSubscriptionRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + }, + "400": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestResponse" + } + } + } + }, + "404": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } } }, + "422": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "subscribe-pages" + ], + "summary": "Delete subscription", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.Unsubscribe subscriber from lists of subscribe page.", + "operationId": "48751c56d39746c6b8dc3f782a9b22e5", + "parameters": [ { - "name": "id", + "name": "pageId", "in": "path", "description": "Subscribe page ID", "required": true, @@ -5086,63 +5231,46 @@ } ], "requestBody": { + "description": "", "required": true, "content": { "application/json": { "schema": { - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string", - "nullable": true - } - }, - "type": "object" + "$ref": "#/components/schemas/PublicUnsubscriptionRequest" } } } }, "responses": { - "200": { - "description": "Success", + "204": { + "description": "Success" + }, + "400": { + "description": "Failure", "content": { "application/json": { "schema": { - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "data": { - "type": "string", - "nullable": true - } - }, - "type": "object" + "$ref": "#/components/schemas/BadRequestResponse" } } } }, - "403": { + "404": { "description": "Failure", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UnauthorizedResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" } } } }, - "404": { - "description": "Not Found", + "422": { + "description": "Failure", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundErrorResponse" + "$ref": "#/components/schemas/ValidationErrorResponse" } } } @@ -8247,6 +8375,51 @@ }, "type": "object" }, + "PublicSubscriptionRequest": { + "required": [ + "email", + "list_id" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "lia@example.com" + }, + "confirm_email": { + "type": "string", + "format": "email", + "example": "lia@example.com" + }, + "list_id": { + "type": "integer", + "example": 1 + }, + "attributes": { + "type": "object", + "example": { + "firstname": "John", + "lastname": "Grigoryan", + "country": "Armenia" + }, + "additionalProperties": true + } + }, + "type": "object" + }, + "PublicUnsubscriptionRequest": { + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "lia@example.com" + } + }, + "type": "object" + }, "SubscriberAttributeDefinitionRequest": { "required": [ "name" @@ -8507,6 +8680,19 @@ }, "type": "object" }, + "SubscribePageData": { + "properties": { + "key": { + "type": "string", + "example": "button" + }, + "value": { + "type": "string", + "example": "Subscribe to the selected newsletters" + } + }, + "type": "object" + }, "SubscribePage": { "properties": { "id": { @@ -8523,6 +8709,106 @@ }, "owner": { "$ref": "#/components/schemas/Administrator" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubscribePageData" + } + } + }, + "type": "object" + }, + "SubscribePagePublic": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Subscribe to our newsletter" + }, + "data": { + "properties": { + "attributes": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default_value": { + "type": "string", + "nullable": true + }, + "list_order": { + "type": "integer" + }, + "options": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + }, + "lists": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "list_position": { + "type": "integer" + } + }, + "type": "object" + } + } + }, + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "object" + } + }, + { + "type": "object" + } + ] + } } }, "type": "object" @@ -8790,6 +9076,10 @@ "name": "subscriptions", "description": "subscriptions" }, + { + "name": "subscribe-pages", + "description": "subscribe-pages" + }, { "name": "subscriber-attributes", "description": "subscriber-attributes" @@ -8803,4 +9093,4 @@ "description": "lists" } ] -} +} \ No newline at end of file From 2b146b3f5728ac9c8b503d0c7ca2035efab29bf6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 11 Jun 2026 16:01:37 +0400 Subject: [PATCH 3/3] Subscribe page (#87) New Features Public "Subscribe Pages" area: directory, editor, create/edit views, and public subscribe/unsubscribe pages with customizable forms and templates Language translation support and a publish flow for phpList texts Bug Fixes / UI Consistent checkbox accent colors and updated border styling for action and pagination buttons Safer login redirects that validate in-site targets Tests Expanded unit, component and integration (Panther) test coverage for UI and public routes --------- Co-authored-by: Tatevik Co-authored-by: github-actions --- .github/workflows/ci.yml | 3 + assets/router/index.js | 5 + assets/styles/color.css | 33 + assets/styles/subscribe.css | 354 ++++++++ assets/vue/api.js | 52 +- assets/vue/components/base/BaseBadge.spec.js | 37 + assets/vue/components/base/BaseCard.spec.js | 75 ++ assets/vue/components/base/BaseCard.vue | 2 +- .../vue/components/base/CkEditorField.spec.js | 184 +++++ .../components/bounces/BounceOverview.spec.js | 255 ++++++ .../vue/components/bounces/BounceOverview.vue | 4 +- .../vue/components/bounces/BouncePer.spec.js | 263 ++++++ .../components/bounces/BounceRules.spec.js | 435 ++++++++++ assets/vue/components/bounces/BounceRules.vue | 4 +- .../bounces/BouncesActionsPanel.spec.js | 206 +++++ .../bounces/BouncesActionsPanel.vue | 2 +- .../campaigns/CampaignDirectory.spec.js | 618 ++++++++++++++ .../campaigns/CampaignDirectory.vue | 12 +- .../campaigns/ViewCampaignModal.spec.js | 375 +++++++++ .../campaigns/ViewCampaignModal.vue | 5 +- .../dashboard/CampaignsTable.spec.js | 130 +++ .../vue/components/dashboard/KpiCard.spec.js | 110 +++ .../vue/components/dashboard/KpiGrid.spec.js | 137 ++++ .../dashboard/PerformanceChartCard.spec.js | 172 ++++ .../dashboard/QuickActionsCard.spec.js | 88 ++ .../dashboard/RecentCampaignsCard.spec.js | 100 +++ .../lists/AddSubscribersModal.spec.js | 227 ++++++ .../components/lists/AddSubscribersModal.vue | 2 +- .../components/lists/CreateListModal.spec.js | 261 ++++++ .../vue/components/lists/CreateListModal.vue | 2 +- .../components/lists/EditListModal.spec.js | 305 +++++++ assets/vue/components/lists/EditListModal.vue | 2 +- .../components/lists/ListDirectory.spec.js | 322 ++++++++ assets/vue/components/lists/ListDirectory.vue | 8 +- .../lists/ListSubscribersExportPanel.spec.js | 282 +++++++ .../lists/ListSubscribersExportPanel.vue | 4 +- .../public-pages/PublicPageEditor.spec.js | 142 ++++ .../public-pages/PublicPageEditor.vue | 762 ++++++++++++++++++ .../public-pages/PublicPagesDirectory.spec.js | 135 ++++ .../public-pages/PublicPagesDirectory.vue | 338 ++++++++ .../vue/components/sidebar/AppSidebar.spec.js | 174 ++++ .../components/sidebar/SidebarLogo.spec.js | 80 ++ .../components/sidebar/SidebarNavItem.spec.js | 188 +++++ .../sidebar/SidebarNavSection.spec.js | 95 +++ .../subscribers/ImportResult.spec.js | 155 ++++ .../subscribers/SubscriberDirectory.spec.js | 289 +++++++ .../subscribers/SubscriberDirectory.vue | 4 +- .../subscribers/SubscriberFilters.spec.js | 160 ++++ .../subscribers/SubscriberModal.spec.js | 312 +++++++ .../subscribers/SubscriberModal.vue | 31 +- .../subscribers/SubscriberTable.spec.js | 147 ++++ .../subscribers/SubscriberTable.vue | 4 +- .../templates/TemplateLibrary.spec.js | 463 +++++++++++ .../components/templates/TemplateLibrary.vue | 4 +- assets/vue/layouts/AdminLayout.spec.js | 337 ++++++++ assets/vue/views/CampaignEditView.vue | 4 +- assets/vue/views/ListSubscribersView.vue | 12 +- assets/vue/views/PublicPageEditView.vue | 12 + assets/vue/views/PublicPagesView.vue | 12 + assets/vue/views/TemplateEditView.vue | 8 +- composer.json | 17 +- config/packages/framework.yaml | 6 + config/packages/security.yaml | 2 + config/services.yml | 11 + openapi.json | 2 +- package.json | 2 +- src/Command/PublishPhplistTextsCommand.php | 104 +++ src/Controller/AuthController.php | 48 +- src/Controller/BaseController.php | 30 + src/Controller/InternalController.php | 38 + src/Controller/PublicPagesController.php | 44 + src/Controller/PublicSubscribeController.php | 177 ++++ .../PhpListFrontendExtension.php | 2 + src/EventSubscriber/AuthGateSubscriber.php | 44 +- .../UnauthorizedSubscriber.php | 29 +- src/Security/SessionAuthenticator.php | 43 +- src/Service/AttributeValidator.php | 53 ++ src/Service/FormDataMapper.php | 185 +++++ src/Service/LanguageService.php | 62 ++ src/Service/ListSelectionService.php | 42 + src/Service/PhpListTranslationLoader.php | 88 ++ src/Service/PublicSubscribeFormBuilder.php | 121 +++ src/Service/PublicSubscribeFormValidator.php | 83 ++ src/Twig/HtmlCleanupExtension.php | 27 + templates/auth/login.html.twig | 3 + templates/base.html.twig | 1 + templates/public/base.html.twig | 23 + templates/public/subscribe.html.twig | 169 ++++ templates/public/unsubscribe.html.twig | 37 + .../Controller/ListsControllerPantherTest.php | 5 +- .../PublicPagesControllerPantherTest.php | 74 ++ .../PublicSubscribeControllerPantherTest.php | 101 +++ .../PublicSubscribeControllerTest.php | 97 +++ tests/Unit/Controller/AuthControllerTest.php | 78 ++ .../AuthGateSubscriberTest.php | 115 +++ .../UnauthorizedSubscriberTest.php | 11 +- .../Security/SessionAuthenticatorTest.php | 32 +- translations/messages.en.phplist | 0 translations/messages.es.phplist | 0 webpack.config.js | 2 + yarn.lock | 8 +- 101 files changed, 10873 insertions(+), 87 deletions(-) create mode 100644 assets/styles/color.css create mode 100644 assets/styles/subscribe.css create mode 100644 assets/vue/components/base/BaseCard.spec.js create mode 100644 assets/vue/components/base/CkEditorField.spec.js create mode 100644 assets/vue/components/bounces/BounceOverview.spec.js create mode 100644 assets/vue/components/bounces/BouncePer.spec.js create mode 100644 assets/vue/components/bounces/BounceRules.spec.js create mode 100644 assets/vue/components/bounces/BouncesActionsPanel.spec.js create mode 100644 assets/vue/components/campaigns/CampaignDirectory.spec.js create mode 100644 assets/vue/components/campaigns/ViewCampaignModal.spec.js create mode 100644 assets/vue/components/dashboard/CampaignsTable.spec.js create mode 100644 assets/vue/components/dashboard/KpiCard.spec.js create mode 100644 assets/vue/components/dashboard/KpiGrid.spec.js create mode 100644 assets/vue/components/dashboard/PerformanceChartCard.spec.js create mode 100644 assets/vue/components/dashboard/QuickActionsCard.spec.js create mode 100644 assets/vue/components/dashboard/RecentCampaignsCard.spec.js create mode 100644 assets/vue/components/lists/AddSubscribersModal.spec.js create mode 100644 assets/vue/components/lists/CreateListModal.spec.js create mode 100644 assets/vue/components/lists/EditListModal.spec.js create mode 100644 assets/vue/components/lists/ListDirectory.spec.js create mode 100644 assets/vue/components/lists/ListSubscribersExportPanel.spec.js create mode 100644 assets/vue/components/public-pages/PublicPageEditor.spec.js create mode 100644 assets/vue/components/public-pages/PublicPageEditor.vue create mode 100644 assets/vue/components/public-pages/PublicPagesDirectory.spec.js create mode 100644 assets/vue/components/public-pages/PublicPagesDirectory.vue create mode 100644 assets/vue/components/sidebar/AppSidebar.spec.js create mode 100644 assets/vue/components/sidebar/SidebarLogo.spec.js create mode 100644 assets/vue/components/sidebar/SidebarNavItem.spec.js create mode 100644 assets/vue/components/sidebar/SidebarNavSection.spec.js create mode 100644 assets/vue/components/subscribers/ImportResult.spec.js create mode 100644 assets/vue/components/subscribers/SubscriberDirectory.spec.js create mode 100644 assets/vue/components/subscribers/SubscriberFilters.spec.js create mode 100644 assets/vue/components/subscribers/SubscriberModal.spec.js create mode 100644 assets/vue/components/subscribers/SubscriberTable.spec.js create mode 100644 assets/vue/components/templates/TemplateLibrary.spec.js create mode 100644 assets/vue/layouts/AdminLayout.spec.js create mode 100644 assets/vue/views/PublicPageEditView.vue create mode 100644 assets/vue/views/PublicPagesView.vue create mode 100644 src/Command/PublishPhplistTextsCommand.php create mode 100644 src/Controller/BaseController.php create mode 100644 src/Controller/InternalController.php create mode 100644 src/Controller/PublicPagesController.php create mode 100644 src/Controller/PublicSubscribeController.php create mode 100644 src/Service/AttributeValidator.php create mode 100644 src/Service/FormDataMapper.php create mode 100644 src/Service/LanguageService.php create mode 100644 src/Service/ListSelectionService.php create mode 100644 src/Service/PhpListTranslationLoader.php create mode 100644 src/Service/PublicSubscribeFormBuilder.php create mode 100644 src/Service/PublicSubscribeFormValidator.php create mode 100644 src/Twig/HtmlCleanupExtension.php create mode 100755 templates/public/base.html.twig create mode 100644 templates/public/subscribe.html.twig create mode 100644 templates/public/unsubscribe.html.twig create mode 100644 tests/Integration/Controller/PublicPagesControllerPantherTest.php create mode 100644 tests/Integration/Controller/PublicSubscribeControllerPantherTest.php create mode 100644 tests/Integration/Controller/PublicSubscribeControllerTest.php create mode 100644 tests/Unit/EventSubscriber/AuthGateSubscriberTest.php create mode 100644 translations/messages.en.phplist create mode 100644 translations/messages.es.phplist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bcc3b..0e19725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,9 @@ jobs: - name: Run tests with phpunit run: vendor/bin/phpunit tests + - name: Run tests with vitest + run: node node_modules/vitest/vitest.mjs --run + - name: Upload Panther screenshots if: failure() uses: actions/upload-artifact@v4 diff --git a/assets/router/index.js b/assets/router/index.js index f1d66d3..a9cbdf0 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -8,6 +8,8 @@ import CampaignEditView from '../vue/views/CampaignEditView.vue' import TemplatesView from '../vue/views/TemplatesView.vue' import TemplateEditView from '../vue/views/TemplateEditView.vue' import BouncesView from '../vue/views/BouncesView.vue' +import PublicPagesView from '../vue/views/PublicPagesView.vue' +import PublicPageEditView from '../vue/views/PublicPageEditView.vue' export const router = createRouter({ history: createWebHistory(), @@ -23,6 +25,9 @@ export const router = createRouter({ { path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } }, { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } }, + { path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } }, + { path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } }, + { path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/styles/color.css b/assets/styles/color.css new file mode 100644 index 0000000..accbd53 --- /dev/null +++ b/assets/styles/color.css @@ -0,0 +1,33 @@ +:root { + --page-bg: #e5e7eb; + --primary-text: #374151; + + --header-bg: #1e1b4b; + --logo-color: #c7d2fe; + + --topbar-gradient-start: #312e81; + --topbar-gradient-middle: #3730a3; + --topbar-gradient-end: #4338ca; + --topbar-border: #4f46e5; + + --card-bg: #ffffff; + --card-border: #e5e7eb; + + --input-bg: #ffffff; + --input-border: #d1d5db; + + --field-frame-bg: #f9fafb; + --field-frame-border: #e5e7eb; + + --required-color: #dc2626; + + --error-bg: #fef2f2; + --error-border: #fca5a5; + --error-text: #dc2626; + + --success-bg: #f0fdf4; + --success-border: #86efac; + --success-text: #16a34a; + + --footer-border: #e5e7eb; +} diff --git a/assets/styles/subscribe.css b/assets/styles/subscribe.css new file mode 100644 index 0000000..2ca756f --- /dev/null +++ b/assets/styles/subscribe.css @@ -0,0 +1,354 @@ +body { + margin: 0; + background: var(--page-bg); + color: var(--primary-text); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} + +#container { + min-width: 300px; + margin: 0 auto; +} + +#header { + background: var(--primary-text); + box-sizing: border-box; + position: relative; + left: 50%; + transform: translateX(-50%); + width: 100vw; +} + +#logo { + color: var(--logo-color); + margin-top: 0px; + font-size: 48px; + text-align: center; + padding-bottom: 20px; +} + +#logo a { + color: var(--logo-color); + text-decoration: none; + font-size: 2rem; +} + +#wrapper { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +#mainContent { + min-height: 500px; +} + +#footer { + padding: 20px; + border-top: 1px solid var(--footer-border); +} + +.inline-field { + display: flex; + align-items: center; + gap: 12px; +} + +.inline-field .legacy-label { + margin: 0; + white-space: nowrap; + min-width: 80px; /* adjust as needed */ +} + +.inline-field .legacy-input { + flex: 1; +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-inline .legacy-label { + margin: 0; +} + +.checkbox-inline input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.checkbox-field label { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 15px; + margin-bottom: 0; +} + +.checkbox-field input[type="checkbox"] { + margin: 0; +} + +.legacy-topbar { + background: linear-gradient(90deg, var(--topbar-gradient-start) 0%, var(--topbar-gradient-middle) 55%, var(--topbar-gradient-end) 100%); + border-bottom: 1px solid var(--topbar-border); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.1); + margin: 10px 0 0; + min-height: 68px; +} + +.legacy-topbar-inner { + box-sizing: border-box; + color: #e0e7ff; + font-size: 33px; + font-weight: 700; + line-height: 1; + margin: 0 auto; + max-width: 930px; + padding: 6px 18px; +} + +.legacy-page-shell { + padding: 40px 16px 60px; +} + +.legacy-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06); + margin: 0 auto; + max-width: 900px; + padding: 52px 30px 36px; +} + +.legacy-form { + margin: 0; +} + +.legacy-html-text { + color: var(--primary-text); + font-size: 13px; + line-height: 1.5; + margin-bottom: 18px; +} + +.legacy-required-note { + color: var(--required-color); + font-size: 12px; + font-weight: 400; + margin: 10px 0 14px; + text-transform: uppercase; +} + +.legacy-field-group { + margin-bottom: 18px; +} + +.legacy-label { + color: var(--primary-text); + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.legacy-input { + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 6px; + box-sizing: border-box; + color: var(--primary-text); + font-size: 13px; + max-width: none; + padding: 7px 10px; + transition: border-color 0.15s, box-shadow 0.15s; + width: 100%; +} + +.legacy-input:focus { + outline: none; + border-color: #543ff6; + box-shadow: 0 0 0 3px rgba(84, 63, 246, 0.12); +} + +.legacy-field-frame { + background: var(--field-frame-bg); + border: 1px solid var(--field-frame-border); + border-radius: 8px; + margin: 14px 0 12px; + padding: 10px; +} + +.legacy-field-row { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: 220px minmax(0, 1fr); + margin-bottom: 12px; +} + +.legacy-field-row .legacy-label { + margin: 0; +} + +.legacy-option-label, +.legacy-list-option, +.legacy-check-label { + color: var(--primary-text); + font-size: 13px; +} + +.legacy-lists { + border: 0; + margin: 16px 0; + padding: 0; +} + +.legacy-lists legend { + color: var(--primary-text); + font-size: 14px; + font-weight: 700; + margin-bottom: 8px; + padding: 0; +} + +.legacy-list-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.legacy-list-option { + align-items: flex-start; + display: flex; + gap: 8px; +} + +.legacy-actions { + align-items: center; + display: flex; + gap: 14px; + margin-top: 10px; +} + +.legacy-button { + background: #543ff6; + border: 1px solid transparent; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 1; + padding: 8px 16px; + transition: background-color 0.15s; +} + +.legacy-button:hover { + background: #303F9F; +} + +.adminmessage { + background: var(--page-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + color: var(--primary-text); + font-size: 12px; + margin-bottom: 14px; + padding: 14px 14px 12px; +} + +.adminmessage p { + margin: 0 0 8px; +} + +.adminmessage p:last-child { + margin-bottom: 0; +} + +.adminmessage .button { + background: #543ff6; + border: 1px solid #fff; + border-radius: 6px; + color: #fff; + display: inline-block; + padding: 4px 8px; + text-decoration: none; +} + +.legacy-confirmation { + color: var(--primary-text); + font-size: 13px; + margin: 10px 0 16px; +} + +.legacy-powered-by { + margin-top: 26px; + text-align: center; +} + +.legacy-powered-by img { + display: inline-block; +} + +.legacy-error-box { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: 6px; + color: var(--error-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-error-box p { + margin: 0 0 6px; +} + +.legacy-error-box p:last-child { + margin-bottom: 0; +} + +.legacy-success { + background: var(--success-bg); + border: 1px solid var(--success-border); + border-radius: 6px; + color: var(--success-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-hidden { + display: none; +} + +input[type="checkbox"] { + accent-color: var(--topbar-gradient-end); +} + +.poweredby { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 9999; +} + +@media (max-width: 720px) { + .legacy-topbar-inner { + font-size: 34px; + } + + .legacy-card { + padding: 28px 16px; + } + + .legacy-field-row { + gap: 6px; + grid-template-columns: 1fr; + } +} diff --git a/assets/vue/api.js b/assets/vue/api.js index 43e2e12..d2de813 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,9 +1,11 @@ import { + AdminClient, CampaignClient, Client, ListMessagesClient, ListClient, StatisticsClient, + SubscribePagesClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient, @@ -27,7 +29,9 @@ const redirectToLogin = () => { return; } isAuthenticationRedirectInProgress = true; - window.location.href = AUTHENTICATION_REDIRECT_PATH; + const redirectTarget = `${window.location.pathname}${window.location.search}`; + const search = new URLSearchParams({ redirect: redirectTarget }).toString(); + window.location.href = `${AUTHENTICATION_REDIRECT_PATH}?${search}`; }; const appElement = document.getElementById('vue-app'); @@ -59,11 +63,13 @@ client.axiosInstance?.interceptors?.response?.use( ); export const subscribersClient = new SubscribersClient(client); +export const adminClient = new AdminClient(client); export const listClient = new ListClient(client); export const campaignClient = new CampaignClient(client); export const listMessagesClient = new ListMessagesClient(client); export const statisticsClient = new StatisticsClient(client); export const subscriptionClient = new SubscriptionClient(client); +export const subscribePagesClient = new SubscribePagesClient(client); export const subscriberAttributesClient = new SubscriberAttributesClient(client); export const templateClient = new TemplatesClient(client); export const bouncesClient = new BouncesClient(client); @@ -100,4 +106,48 @@ export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { return lists; }; +export const fetchAllAdmins = async ({ limit = 100, maxPages = 100 } = {}) => { + const admins = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await adminClient.getAdministrators(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + admins.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return admins; +}; + +export const fetchAllAttributeDefinitions = async ({ limit = 100, maxPages = 100 } = {}) => { + const attributes = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await subscriberAttributesClient.getAttributeDefinitions(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + attributes.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return attributes; +}; + export default client; diff --git a/assets/vue/components/base/BaseBadge.spec.js b/assets/vue/components/base/BaseBadge.spec.js index df0507a..3728e8a 100644 --- a/assets/vue/components/base/BaseBadge.spec.js +++ b/assets/vue/components/base/BaseBadge.spec.js @@ -2,6 +2,19 @@ import { mount } from '@vue/test-utils' import BaseBadge from './BaseBadge.vue' describe('BaseBadge', () => { + it('applies shared base badge classes', () => { + const wrapper = mount(BaseBadge) + const classes = wrapper.get('span').classes() + + expect(classes).toContain('inline-flex') + expect(classes).toContain('items-center') + expect(classes).toContain('px-2') + expect(classes).toContain('py-0.5') + expect(classes).toContain('rounded-full') + expect(classes).toContain('text-xs') + expect(classes).toContain('font-medium') + }) + it('renders neutral variant by default', () => { const wrapper = mount(BaseBadge, { slots: { @@ -28,6 +41,30 @@ describe('BaseBadge', () => { const classes = wrapper.get('span').classes() expect(classes).toContain('bg-indigo-50') expect(classes).toContain('text-ext-wf3') + expect(classes).toContain('border') + expect(classes).toContain('border-indigo-100') expect(wrapper.text()).toContain('10') }) + + it('falls back to neutral styles for unknown variant', () => { + const wrapper = mount(BaseBadge, { + props: { + variant: 'unexpected', + }, + }) + + const classes = wrapper.get('span').classes() + expect(classes).toContain('bg-gray-100') + expect(classes).toContain('text-gray-800') + }) + + it('forwards attributes to the root span', () => { + const wrapper = mount(BaseBadge, { + attrs: { + 'data-testid': 'badge', + }, + }) + + expect(wrapper.get('span').attributes('data-testid')).toBe('badge') + }) }) diff --git a/assets/vue/components/base/BaseCard.spec.js b/assets/vue/components/base/BaseCard.spec.js new file mode 100644 index 0000000..eaaad91 --- /dev/null +++ b/assets/vue/components/base/BaseCard.spec.js @@ -0,0 +1,75 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import BaseCard from './BaseCard.vue' + +describe('BaseCard.vue', () => { + describe('default variant', () => { + it('renders with default variant when no prop is passed', () => { + const wrapper = mount(BaseCard) + expect(wrapper.classes()).toContain('rounded-lg') + expect(wrapper.classes()).toContain('shadow-sm') + expect(wrapper.classes()).toContain('border') + expect(wrapper.classes()).toContain('border-gray-100') + expect(wrapper.classes()).toContain('bg-white') + }) + + it('renders body with default padding', () => { + const wrapper = mount(BaseCard) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('subtle variant', () => { + it('applies subtle card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'subtle' } }) + expect(wrapper.classes()).toContain('bg-gray-50') + expect(wrapper.classes()).toContain('border-0') + expect(wrapper.classes()).not.toContain('bg-white') + }) + + it('applies subtle body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'subtle' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('danger variant', () => { + it('applies danger card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'danger' } }) + expect(wrapper.classes()).toContain('bg-red-600') + expect(wrapper.classes()).toContain('text-white') + expect(wrapper.classes()).toContain('border-0') + }) + + it('applies danger body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'danger' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('success variant', () => { + it('applies success card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'success' } }) + expect(wrapper.classes()).toContain('bg-green-600') + expect(wrapper.classes()).toContain('text-white') + expect(wrapper.classes()).toContain('border-0') + }) + + it('applies success body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'success' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('unknown variant fallback', () => { + it('falls back to default classes for an unrecognised variant', () => { + const wrapper = mount(BaseCard, { props: { variant: 'ghost' } }) + expect(wrapper.classes()).toContain('bg-white') + expect(wrapper.classes()).toContain('border-gray-100') + }) + }) +}) diff --git a/assets/vue/components/base/BaseCard.vue b/assets/vue/components/base/BaseCard.vue index 2278c74..5a15a04 100644 --- a/assets/vue/components/base/BaseCard.vue +++ b/assets/vue/components/base/BaseCard.vue @@ -1,6 +1,6 @@