From 5d745dde403987b77f26d1837efc4300054d6665 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 18 May 2026 14:36:17 +0400 Subject: [PATCH 01/61] dev --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 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", From 019f72805641ae8f9739b65a8b132a2738a492fb Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 19 May 2026 15:36:25 +0400 Subject: [PATCH 02/61] PublicPagesView --- assets/router/index.js | 2 + assets/vue/api.js | 2 + .../public-pages/PublicPagesDirectory.vue | 518 ++++++++++++++++++ assets/vue/views/PublicPagesView.vue | 12 + src/Controller/PublicPagesController.php | 24 + 5 files changed, 558 insertions(+) create mode 100644 assets/vue/components/public-pages/PublicPagesDirectory.vue create mode 100644 assets/vue/views/PublicPagesView.vue create mode 100644 src/Controller/PublicPagesController.php diff --git a/assets/router/index.js b/assets/router/index.js index f1d66d3..5cbb80d 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -8,6 +8,7 @@ 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' export const router = createRouter({ history: createWebHistory(), @@ -23,6 +24,7 @@ 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: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/api.js b/assets/vue/api.js index 43e2e12..5e92b02 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -4,6 +4,7 @@ import { ListMessagesClient, ListClient, StatisticsClient, + SubscribePagesClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient, @@ -64,6 +65,7 @@ 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); diff --git a/assets/vue/components/public-pages/PublicPagesDirectory.vue b/assets/vue/components/public-pages/PublicPagesDirectory.vue new file mode 100644 index 0000000..52d3358 --- /dev/null +++ b/assets/vue/components/public-pages/PublicPagesDirectory.vue @@ -0,0 +1,518 @@ + + + diff --git a/assets/vue/views/PublicPagesView.vue b/assets/vue/views/PublicPagesView.vue new file mode 100644 index 0000000..bd247ea --- /dev/null +++ b/assets/vue/views/PublicPagesView.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/Controller/PublicPagesController.php b/src/Controller/PublicPagesController.php new file mode 100644 index 0000000..245e408 --- /dev/null +++ b/src/Controller/PublicPagesController.php @@ -0,0 +1,24 @@ +render('@PhpListFrontend/spa.html.twig', [ + 'page' => 'Public Pages', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } +} From 4fe8767e0bc036580d0ef19236c9132fd600015f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 19 May 2026 15:53:58 +0400 Subject: [PATCH 03/61] PublicPageEditor --- assets/router/index.js | 3 + assets/vue/api.js | 46 ++ .../public-pages/PublicPageEditor.vue | 608 ++++++++++++++++++ .../public-pages/PublicPagesDirectory.vue | 47 +- assets/vue/views/PublicPageEditView.vue | 12 + src/Controller/PublicPagesController.php | 25 +- 6 files changed, 697 insertions(+), 44 deletions(-) create mode 100644 assets/vue/components/public-pages/PublicPageEditor.vue create mode 100644 assets/vue/views/PublicPageEditView.vue diff --git a/assets/router/index.js b/assets/router/index.js index 5cbb80d..a9cbdf0 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -9,6 +9,7 @@ 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(), @@ -25,6 +26,8 @@ export const router = createRouter({ { 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/vue/api.js b/assets/vue/api.js index 5e92b02..4749ba1 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,4 +1,5 @@ import { + AdminClient, CampaignClient, Client, ListMessagesClient, @@ -60,6 +61,7 @@ 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); @@ -102,4 +104,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/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue new file mode 100644 index 0000000..5d202a2 --- /dev/null +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -0,0 +1,608 @@ + + + diff --git a/assets/vue/components/public-pages/PublicPagesDirectory.vue b/assets/vue/components/public-pages/PublicPagesDirectory.vue index 52d3358..885f65a 100644 --- a/assets/vue/components/public-pages/PublicPagesDirectory.vue +++ b/assets/vue/components/public-pages/PublicPagesDirectory.vue @@ -239,6 +239,7 @@ diff --git a/src/Controller/PublicPagesController.php b/src/Controller/PublicPagesController.php index 245e408..e155891 100644 --- a/src/Controller/PublicPagesController.php +++ b/src/Controller/PublicPagesController.php @@ -13,10 +13,33 @@ class PublicPagesController extends AbstractController { #[Route('/', name: 'pages', methods: ['GET'])] + #[Route('', name: 'pages_no_slash', methods: ['GET'])] public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ - 'page' => 'Public Pages', + 'page' => 'Subscribe Pages', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } + + #[Route('/create', name: 'create', methods: ['GET'])] + #[Route('/create/', name: 'create_with_slash', methods: ['GET'])] + public function create(Request $request): Response + { + return $this->render('@PhpListFrontend/spa.html.twig', [ + 'page' => 'Create Subscribe Page', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } + + #[Route('/{pageId}/edit', name: 'edit', methods: ['GET'])] + #[Route('/{pageId}/edit/', name: 'edit_with_slash', methods: ['GET'])] + public function edit(Request $request, int $pageId): Response + { + return $this->render('@PhpListFrontend/spa.html.twig', [ + 'page' => sprintf('Edit Subscribe Page #%d', $pageId), 'api_token' => $request->getSession()->get('auth_token'), 'api_base_url' => $this->getParameter('api_base_url'), ]); From 4d120c4db01b5564abaae991eb52c3e44024ec03 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 May 2026 10:13:59 +0400 Subject: [PATCH 04/61] update PublicPageEditor --- .../public-pages/PublicPageEditor.vue | 406 ++++++++++-------- 1 file changed, 218 insertions(+), 188 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index 5d202a2..f4579ab 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -27,241 +27,259 @@ Loading public page data... -
-
-
- - - +
+
+
+ +
+
- +
+
+

General settings

+
+ - + - + - + - + - + -
- HTML Email choice -
+
+ HTML Email choice + + +
+ +
+ Display email address confirmation field + + +
+
+
+ +
+

Select the lists to offer

- Display email address confirmation field + Display list categories
-
-
-
-

Select the lists to offer

-
- Display list categories - -
- +
+
+
+ + +
+
+
+
-
+
+

Select the attributes to use

-
-
-
- - -
-

Select the attributes to use

-
-
-

Attribute: {{ attribute.id }}

+ -
+
+
-
-
-

Name

-

{{ attribute.name }}

-
-
-

Type

-

{{ attribute.type }}

-
+
+

Transaction messages

+
+

Message subscribers receive when they subscribe

- - -
- -
-

Transaction messages

- -
-

Message subscribers receive when they subscribe

- - -
- -
-

Message they receive when they confirm their subscription

- - -
+
+

Message they receive when they confirm their subscription

+ + +
-
-

Content of the message they receive when they unsubscribe

- -
+ +
+

Owner

+ -
- - -
-

Owner

- -
+ +
@@ -287,6 +305,14 @@ const isSaving = ref(false) const admins = ref([]) const lists = ref([]) const attributes = ref([]) +const currentStep = ref(1) +const steps = [ + { id: 1, label: 'General' }, + { id: 2, label: 'Lists' }, + { id: 3, label: 'Attributes' }, + { id: 4, label: 'Messages' }, + { id: 5, label: 'Owner' } +] const attributeConfig = ref({}) const dataMap = ref({}) @@ -326,6 +352,10 @@ const languageOptions = computed(() => { const publicLists = computed(() => lists.value.filter((list) => list.public === true)) +const goToStep = (stepId) => { + currentStep.value = stepId +} + const parseBoolean = (value, fallback = false) => { if (value === null || value === undefined || value === '') { return fallback From 99632bcf81a8335ed45ae8b2824925862ff4046f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 May 2026 11:05:07 +0400 Subject: [PATCH 05/61] translation files --- .../public-pages/PublicPageEditor.vue | 29 +++++-- composer.json | 9 +- config/packages/framework.yaml | 6 ++ config/services.yml | 6 ++ src/Controller/InternalController.php | 37 ++++++++ src/Service/PhpListTranslationLoader.php | 85 +++++++++++++++++++ translations/messages.en.phplist | 0 translations/messages.es.phplist | 0 8 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/Controller/InternalController.php create mode 100644 src/Service/PhpListTranslationLoader.php create mode 100644 translations/messages.en.phplist create mode 100644 translations/messages.es.phplist diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index f4579ab..30149a5 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -291,7 +291,8 @@ import apiClient, { fetchAllAdmins, fetchAllAttributeDefinitions, fetchAllLists, - subscribePagesClient + subscribePagesClient, + backendFetch } from '../../api' const route = useRoute() @@ -340,14 +341,30 @@ const form = ref({ ownerId: '' }) -const DEFAULT_LANGUAGE_OPTIONS = ['english.inc', 'francais.inc', 'deutsch.inc', 'espanol.inc', 'italiano.inc', 'russian.inc'] +const defaultLanguageOptions = ref(['english.inc']) + +const loadDefaultLanguageOptions = async () => { + try { + const resp = await backendFetch('/_internal/languages') + console.log('Default language options:', resp) + if (resp && resp.ok) { + const items = await resp.json() + if (Array.isArray(items) && items.length > 0) { + defaultLanguageOptions.value = items + } + } + } catch (error) { + // keep default hardcoded list on error + console.error('Failed to load default language options:', error) + } +} const languageOptions = computed(() => { const current = form.value.languageFile?.trim() - if (!current || DEFAULT_LANGUAGE_OPTIONS.includes(current)) { - return DEFAULT_LANGUAGE_OPTIONS + if (!current || defaultLanguageOptions.value.includes(current)) { + return defaultLanguageOptions.value } - return [current, ...DEFAULT_LANGUAGE_OPTIONS] + return [current, ...defaultLanguageOptions.value] }) const publicLists = computed(() => lists.value.filter((list) => list.public === true)) @@ -502,6 +519,8 @@ const loadInitialData = async () => { isLoading.value = true try { + // fetch language options from vendor package (exposed via internal controller) + await loadDefaultLanguageOptions() const [fetchedAdmins, fetchedLists, fetchedAttributes] = await Promise.all([ fetchAllAdmins(), fetchAllLists(), diff --git a/composer.json b/composer.json index af06be9..4a99b68 100755 --- a/composer.json +++ b/composer.json @@ -19,6 +19,10 @@ { "type": "vcs", "url": "https://github.com/tatevikgr/rss-bundle.git" + }, + { + "type": "vcs", + "url": "https://github.com/phpList/phplist-lan-texts" } ], "minimum-stability": "dev", @@ -47,11 +51,12 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-dev", + "phplist/core": "dev-main", "symfony/twig-bundle": "^6.4", "symfony/webpack-encore-bundle": "^2.2", "symfony/security-bundle": "^6.4", - "tatevikgr/rest-api-client": "dev-dev" + "tatevikgr/rest-api-client": "dev-main", + "phplist/phplist-lan-texts": "^2021.05" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index d7dbf70..06ba328 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -8,3 +8,9 @@ framework: handler_id: null cookie_secure: auto cookie_samesite: lax + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - en + providers: ~ + # Force Symfony to scan our custom public files path via empty files in translations/ diff --git a/config/services.yml b/config/services.yml index 106c9c3..36e19fc 100755 --- a/config/services.yml +++ b/config/services.yml @@ -30,3 +30,9 @@ services: PhpList\WebFrontend\EventListener\ApiSessionListener: tags: - { name: kernel.event_subscriber } + + PhpList\WebFrontend\Service\PhpListTranslationLoader: + arguments: + $projectDir: '%kernel.project_dir%' + tags: + - { name: translation.loader, alias: phplist } diff --git a/src/Controller/InternalController.php b/src/Controller/InternalController.php new file mode 100644 index 0000000..f3b987c --- /dev/null +++ b/src/Controller/InternalController.php @@ -0,0 +1,37 @@ +getParameter('kernel.project_dir'); + $langDir = $projectDir . '/vendor/phplist/phplist-lan-texts'; + + $files = []; + if (is_dir($langDir)) { + $dirItems = scandir($langDir); + if (is_array($dirItems)) { + foreach ($dirItems as $item) { + if (is_file($langDir . '/' . $item) && preg_match('/\.inc$/i', $item)) { + $files[] = $item; + } + } + } + } + + sort($files, SORT_STRING); + + return $this->json($files); + } +} diff --git a/src/Service/PhpListTranslationLoader.php b/src/Service/PhpListTranslationLoader.php new file mode 100644 index 0000000..64a56dc --- /dev/null +++ b/src/Service/PhpListTranslationLoader.php @@ -0,0 +1,85 @@ +publicDir = $projectDir . '/public/lists/texts/'; + } + + public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue + { + $catalogue = new MessageCatalogue($locale); + + // Map Symfony locales (e.g., 'en') to phpList filenames (e.g., 'english.php') + $fileName = $this->mapLocaleToPhpListFile($locale); + $filePath = $this->publicDir . $fileName; + + if (!file_exists($filePath)) { + return $catalogue; + } + + // Isolate scope and include the raw file to capture phpList variables + $phpListTranslations = $this->extractVariables($filePath); + + $catalogue->add($phpListTranslations, $domain); + + return $catalogue; + } + + private function extractVariables(string $filePath): array + { + // Include the file inside an isolated closure + $getVars = function ($file) { + include $file; + return get_defined_vars(); + }; + + $allVars = $getVars($filePath); + $flattened = []; + + foreach ($allVars as $key => $value) { + // Filter out internal closure parameters + if ($key === 'file') { + continue; + }; + + // phpList3 handles both $strVariable and $lan['variable'] arrays + if ($key === 'lan' && is_array($value)) { + foreach ($value as $subKey => $subValue) { + $flattened['lan.' . $subKey] = $subValue; + } + } elseif (is_string($value)) { + $flattened[$key] = $value; + } + } + + return $flattened; + } + + private function mapLocaleToPhpListFile(string $locale): string + { + $map = [ + 'en' => 'english.php', + 'es' => 'spanish.php', + 'fr' => 'french.php', + ]; + + return $map[$locale] ?? 'english.php'; + } +} + +// USAGE +// If the file contains $strPleaseEnter +// +// +// If the file contains $lan['money'] +//

{{ 'lan.money'|trans }}

diff --git a/translations/messages.en.phplist b/translations/messages.en.phplist new file mode 100644 index 0000000..e69de29 diff --git a/translations/messages.es.phplist b/translations/messages.es.phplist new file mode 100644 index 0000000..e69de29 From b7f082566e620d488ee779069c5bceaf4c1dec35 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 20 May 2026 11:19:00 +0400 Subject: [PATCH 06/61] translation files publish --- composer.json | 9 +- config/services.yml | 2 + src/Command/PublishPhplistTextsCommand.php | 104 +++++++++++++++++++++ src/Controller/InternalController.php | 5 +- 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/Command/PublishPhplistTextsCommand.php diff --git a/composer.json b/composer.json index 4a99b68..9bbe6da 100755 --- a/composer.json +++ b/composer.json @@ -96,13 +96,18 @@ "PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::clearAllCaches" ], + "publish-phplist-texts": [ + "@php bin/console web-frontend:publish-phplist-texts" + ], "post-install-cmd": [ "@create-directories", - "@update-configuration" + "@update-configuration", + "@publish-phplist-texts" ], "post-update-cmd": [ "@create-directories", - "@update-configuration" + "@update-configuration", + "@publish-phplist-texts" ] }, "extra": { diff --git a/config/services.yml b/config/services.yml index 36e19fc..3b4705e 100755 --- a/config/services.yml +++ b/config/services.yml @@ -8,6 +8,8 @@ services: autowire: true autoconfigure: true public: false + bind: + $projectDir: '%kernel.project_dir%' PhpList\WebFrontend\: resource: '../src/' diff --git a/src/Command/PublishPhplistTextsCommand.php b/src/Command/PublishPhplistTextsCommand.php new file mode 100644 index 0000000..8d25d74 --- /dev/null +++ b/src/Command/PublishPhplistTextsCommand.php @@ -0,0 +1,104 @@ +projectDir = $projectDir; + $this->filesystem = new Filesystem(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $sourceDir = $this->resolveTextsSourceDir(); + $targetDir = $this->resolveTargetDir(); + + if ($sourceDir === null || !$this->filesystem->exists($sourceDir)) { + $io->error('The phplist-lan-texts package is not installed in the vendor directory.'); + return Command::FAILURE; + } + + try { + // Ensure the target directory exists and is clean + if ($this->filesystem->exists($targetDir)) { + $this->filesystem->remove($targetDir); + } + $this->filesystem->mkdir($targetDir); + + // Copy all .inc files from vendor to public directory + foreach (glob($sourceDir . '/*.inc') as $file) { + $filename = basename($file); + $this->filesystem->copy($file, $targetDir . '/' . $filename, true); + } + + $io->success('phpList translation files successfully published to public/lists/texts/'); + return Command::SUCCESS; + } catch (Exception $e) { + $io->error('An error occurred while copying files: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + private function resolveTextsSourceDir(): ?string + { + if (class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('phplist/phplist-lan-texts')) { + $installPath = InstalledVersions::getInstallPath('phplist/phplist-lan-texts'); + if (is_string($installPath) && $installPath !== '') { + return $installPath; + } + } + + $applicationRoot = $this->resolveApplicationRoot(); + $candidates = [ + $applicationRoot . '/vendor/phplist/phplist-lan-texts', + $this->projectDir . '/vendor/phplist/phplist-lan-texts', + ]; + + foreach ($candidates as $candidate) { + if ($this->filesystem->exists($candidate)) { + return $candidate; + } + } + + return null; + } + + private function resolveTargetDir(): string + { + return $this->resolveApplicationRoot() . '/public/lists/texts'; + } + + private function resolveApplicationRoot(): string + { + try { + return (new ApplicationStructure())->getApplicationRoot(); + } catch (RuntimeException) { + return $this->projectDir; + } + } +} diff --git a/src/Controller/InternalController.php b/src/Controller/InternalController.php index f3b987c..95c5e06 100644 --- a/src/Controller/InternalController.php +++ b/src/Controller/InternalController.php @@ -4,6 +4,7 @@ namespace PhpList\WebFrontend\Controller; +use PhpList\Core\Core\ApplicationStructure; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -15,8 +16,8 @@ class InternalController extends AbstractController #[Route('/languages', name: 'languages', methods: ['GET'])] public function languages(Request $request): JsonResponse { - $projectDir = $this->getParameter('kernel.project_dir'); - $langDir = $projectDir . '/vendor/phplist/phplist-lan-texts'; + $applicationRoot = (new ApplicationStructure())->getApplicationRoot(); + $langDir = $applicationRoot . '/public/lists/texts'; $files = []; if (is_dir($langDir)) { From b97a87629f95ac40b53f8d285b7e887613ebae32 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 20 May 2026 11:08:24 +0000 Subject: [PATCH 07/61] Update openapi.json from web frontend workflow 2026-05-20T11:08:24Z --- openapi.json | 195 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 144 insertions(+), 51 deletions(-) diff --git a/openapi.json b/openapi.json index 722bd8a..afa96f9 100644 --- a/openapi.json +++ b/openapi.json @@ -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" ], - "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" ], - "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,8 +4834,7 @@ "schema": { "properties": { "title": { - "type": "string", - "nullable": true + "type": "string" }, "active": { "type": "boolean", @@ -4824,8 +4847,8 @@ } }, "responses": { - "200": { - "description": "Success", + "201": { + "description": "Created", "content": { "application/json": { "schema": { @@ -4844,25 +4867,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" ], - "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 +4909,15 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscribePage" + } + } + } }, "403": { "description": "Failure", @@ -4908,16 +4940,14 @@ } } } - } - }, - "/api/v2/subscribe-pages": { - "post": { + }, + "put": { "tags": [ "subscriptions" ], - "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 +4957,15 @@ "schema": { "type": "string" } + }, + { + "name": "id", + "in": "path", + "description": "Subscribe page ID", + "required": true, + "schema": { + "type": "integer" + } } ], "requestBody": { @@ -4936,7 +4975,8 @@ "schema": { "properties": { "title": { - "type": "string" + "type": "string", + "nullable": true }, "active": { "type": "boolean", @@ -4949,8 +4989,8 @@ } }, "responses": { - "201": { - "description": "Created", + "200": { + "description": "Success", "content": { "application/json": { "schema": { @@ -4969,12 +5009,65 @@ } } }, - "422": { - "description": "Validation failed", + "404": { + "description": "Not Found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "subscriptions" + ], + "summary": "Delete subscribe page", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production.", + "operationId": "2881b5de1d076caa070f2b9a1c8487fe", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "description": "Subscribe page ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponse" } } } @@ -8803,4 +8896,4 @@ "description": "lists" } ] -} +} \ No newline at end of file From 8b3de41fc67513d783dadc7d7712aabb0e628d7e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 21 May 2026 14:32:07 +0400 Subject: [PATCH 08/61] Fix: get pages client pagination --- .../public-pages/PublicPagesDirectory.vue | 49 +++++++++---------- package.json | 2 +- yarn.lock | 8 +-- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPagesDirectory.vue b/assets/vue/components/public-pages/PublicPagesDirectory.vue index 885f65a..58c338d 100644 --- a/assets/vue/components/public-pages/PublicPagesDirectory.vue +++ b/assets/vue/components/public-pages/PublicPagesDirectory.vue @@ -244,8 +244,6 @@ import { Requests } from '@tatevikgr/rest-api-client' import BaseIcon from '../base/BaseIcon.vue' import apiClient, { subscribePagesClient } from '../../api' -const MAX_PROBED_PAGES = 50 -const PROBE_CHUNK_SIZE = 10 const router = useRouter() const subscribePages = ref([]) @@ -315,33 +313,30 @@ const mapSubscribePage = async (page) => { } } -const fetchSubscribePages = async () => { - const discoveredPages = [] - - for (let startId = 1; startId <= MAX_PROBED_PAGES; startId += PROBE_CHUNK_SIZE) { - const ids = Array.from( - { length: Math.min(PROBE_CHUNK_SIZE, MAX_PROBED_PAGES - startId + 1) }, - (_, index) => startId + index - ) - - const chunkResults = await Promise.all( - ids.map(async (id) => { - try { - const page = await subscribePagesClient.getSubscribePage(id) - return await mapSubscribePage(page) - } catch (error) { - if (isNotFoundError(error)) { - return null - } - throw error - } - }) - ) - - discoveredPages.push(...chunkResults.filter(Boolean)) +const fetchSubscribePages = async ({ limit = 100, maxPages = 100 } = {}) => { + const pages = [] + let afterId = null + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + // The SubscribePagesClient does not expose a paginated "getSubscribePages" helper. + // Use the generic API client to fetch the list endpoint instead. + const response = await apiClient.get('subscribe-pages', { params: { afterId, limit } }) + const items = Array.isArray(response?.items) ? response.items : [] + + const mappedItems = await Promise.all(items.map((page) => mapSubscribePage(page))) + pages.push(...mappedItems) + + const hasMore = response?.pagination?.hasMore === true + const nextCursor = response?.pagination?.nextCursor + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break + } + + afterId = nextCursor } - return discoveredPages.sort((a, b) => a.id - b.id) + return pages.sort((a, b) => a.id - b.id) } const loadSubscribePages = async () => { diff --git a/package.json b/package.json index 7772419..d50dee4 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@ckeditor/ckeditor5-vue": "^7.4.0", - "@tatevikgr/rest-api-client": "^2.1.7", + "@tatevikgr/rest-api-client": "^2.1.8", "apexcharts": "^5.10.4", "ckeditor5": "^48.0.0", "vue": "^3.5.16", diff --git a/yarn.lock b/yarn.lock index 19956d6..af7b1cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2119,10 +2119,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^2.1.7": - version "2.1.7" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.7.tgz#7765e4c3d95dc3854415be392ec76a5f985ec7e3" - integrity sha512-xHyRzRCHfJ0YhGJi3JB+Hib2+dkd3+JaioYarHgv/WSGLmzxgimYwUBGDm4KDiSP0CkgGi/TvUVuLU3suZokgg== +"@tatevikgr/rest-api-client@^2.1.8": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.9.tgz#72dea37787fc31543a5f7a448b1e387e64c36466" + integrity sha512-4/9h3Bg2yRYiICMhr5uX6ZoRnnD/gNE/cFb7w1IycekEBjWaTyfsz4C4MB724gvWRPwUTRxxoqox3XoEW3xgHQ== dependencies: axios "^1.6.0" From 07cb8900d381c9039a889dff1c50b598633bda46 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 21 May 2026 16:41:02 +0400 Subject: [PATCH 09/61] Fix: form value keys --- .../public-pages/PublicPageEditor.vue | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index 30149a5..e005380 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -62,7 +62,7 @@
- HTML Email choice + HTML email choice + + + +
@@ -316,6 +332,7 @@ const steps = [ ] const attributeConfig = ref({}) const dataMap = ref({}) +const legacyHtmlChoiceOptions = new Set(['textonly', 'htmlonly', 'checkfortext', 'checkforhtml', 'radiotext', 'radiohtml']) const form = ref({ title: '', @@ -326,7 +343,7 @@ const form = ref({ thankYouPageText: '', ajaxSuccessText: '', button: '', - htmlEmailChoice: '1', + htmlChoice: 'checkforhtml', displayEmailConfirmationField: '0', displayListCategories: '1', noPreselectAnyList: false, @@ -484,7 +501,10 @@ const applyLoadedDataToForm = (page = null) => { form.value.emailDoubleEntry = parseBoolean(getDataValue('emaildoubleentry', false)) form.value.footerText = getDataValue('footer', '') form.value.headerText = getDataValue('header', '') - form.value.htmlChoice = getDataValue('htmlchoice', '') + const legacyHtmlChoice = getDataValue('htmlchoice', '').trim().toLowerCase() + form.value.htmlChoice = legacyHtmlChoiceOptions.has(legacyHtmlChoice) + ? legacyHtmlChoice + : (parseBoolean(getDataValue('html_email_choice', '1'), true) ? 'checkforhtml' : 'textonly') form.value.introText = getDataValue('intro', '') form.value.languageFile = getDataValue('language_file', 'english.inc') form.value.selectedListIds = parseIdArray(getDataValue('lists', '')) @@ -493,7 +513,6 @@ const applyLoadedDataToForm = (page = null) => { form.value.thankYouPageText = getDataValue('thankyoupage', '') form.value.title = getDataValue('title', '') - form.value.htmlEmailChoice = parseBoolean(getDataValue('html_email_choice', '1'), true) ? '1' : '0' form.value.displayEmailConfirmationField = parseBoolean(getDataValue('email_confirmation_field', '0')) ? '1' : '0' form.value.noPreselectAnyList = parseBoolean(getDataValue('no_preselect_any_list', '0')) form.value.subscribeSubject = getDataValue('tx_subscribe_subject', '') @@ -580,7 +599,7 @@ const persistDataItems = async (id) => { ['thankyoupage', form.value.thankYouPageText], ['ajax_subscribeconfirmation', form.value.ajaxSuccessText], ['button', form.value.button], - ['html_email_choice', form.value.htmlEmailChoice], + ['htmlchoice', form.value.htmlChoice], ['email_confirmation_field', form.value.displayEmailConfirmationField], ['showcategories', form.value.displayListCategories], ['no_preselect_any_list', form.value.noPreselectAnyList ? '1' : '0'], From e514616cfa5bcbef383716feed3a90811cde2014 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 21 May 2026 17:59:45 +0400 Subject: [PATCH 11/61] Fix: displayEmailConfirmationField --- assets/vue/components/public-pages/PublicPageEditor.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index be0f9db..fe9360f 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -498,7 +498,6 @@ const applyLoadedDataToForm = (page = null) => { form.value.ajaxSuccessText = getDataValue('ajax_subscribeconfirmation', '') form.value.attributes = getDataValue('attributes', '') form.value.button = getDataValue('button', '') - form.value.emailDoubleEntry = parseBoolean(getDataValue('emaildoubleentry', false)) form.value.footerText = getDataValue('footer', '') form.value.headerText = getDataValue('header', '') const legacyHtmlChoice = getDataValue('htmlchoice', '').trim().toLowerCase() @@ -513,7 +512,7 @@ const applyLoadedDataToForm = (page = null) => { form.value.thankYouPageText = getDataValue('thankyoupage', '') form.value.title = getDataValue('title', '') - form.value.displayEmailConfirmationField = parseBoolean(getDataValue('email_confirmation_field', '0')) ? '1' : '0' + form.value.displayEmailConfirmationField = parseBoolean(getDataValue('emaildoubleentry', '0')) ? '1' : '0' form.value.noPreselectAnyList = parseBoolean(getDataValue('no_preselect_any_list', '0')) form.value.subscribeSubject = getDataValue('tx_subscribe_subject', '') form.value.subscribeMessage = getDataValue('tx_subscribe_message', '') @@ -611,7 +610,8 @@ const persistDataItems = async (id) => { ['tx_confirm_message', form.value.confirmedMessage], ['tx_unsubscribe_subject', form.value.unsubscribeSubject], ['tx_unsubscribe_message', form.value.unsubscribeMessage], - ['owner_id', form.value.ownerId] + ['owner_id', form.value.ownerId], + ['emaildoubleentry', form.value.displayEmailConfirmationField ? 'Yes' : 'No'], ] attributes.value.forEach((attribute) => { From 3c9c0e2d5ebc0049f277ac9939b84573aa66622d Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 23 May 2026 12:37:01 +0000 Subject: [PATCH 12/61] Update openapi.json from web frontend workflow 2026-05-23T12:37:01Z --- openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index afa96f9..8f1f84d 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", From d4da85a082292a3aeb4b5ebd06ba696e8bf7ef9a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 May 2026 11:09:48 +0400 Subject: [PATCH 13/61] Get data items from page object --- assets/vue/components/public-pages/PublicPageEditor.vue | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index fe9360f..13d40d8 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -479,14 +479,11 @@ const toggleListPreselection = (listId, event) => { form.value.preselectedListIds = Array.from(preselected).sort((a, b) => a - b) } -const loadPageDataMap = async (id) => { - const items = await apiClient.get(`subscribe-pages/${id}/data`) +const loadPageDataMap = (items) => { const map = {} if (Array.isArray(items)) { items.forEach((item) => { - if (typeof item?.name === 'string') { - map[item.name] = item.data - } + map[item.key] = item.value }) } @@ -555,7 +552,7 @@ const loadInitialData = async () => { if (isEditMode.value) { const page = await subscribePagesClient.getSubscribePage(pageId.value) - await loadPageDataMap(pageId.value) + loadPageDataMap(page?.data) applyLoadedDataToForm(page) } else { dataMap.value = {} From af997f69040db610b5ac92c57d31e21256d08a35 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 25 May 2026 11:18:02 +0400 Subject: [PATCH 14/61] Fix preselection logic --- .../public-pages/PublicPageEditor.vue | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/assets/vue/components/public-pages/PublicPageEditor.vue b/assets/vue/components/public-pages/PublicPageEditor.vue index 13d40d8..a792ef2 100644 --- a/assets/vue/components/public-pages/PublicPageEditor.vue +++ b/assets/vue/components/public-pages/PublicPageEditor.vue @@ -169,7 +169,7 @@