From 4df20e87323f4e56dfc045e51d4728e4d1426da6 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 24 Mar 2026 14:01:59 +0400 Subject: [PATCH 1/6] ListsController --- assets/router/index.js | 2 + assets/vue/api.js | 5 +- .../components/lists/AddSubscribersModal.vue | 165 +++++++++ .../vue/components/lists/CreateListModal.vue | 181 ++++++++++ assets/vue/components/lists/EditListModal.vue | 244 ++++++++++++++ assets/vue/components/lists/ListDirectory.vue | 312 ++++++++++++++++++ assets/vue/views/ListsView.vue | 12 + package.json | 2 +- src/Controller/ListsController.php | 35 ++ yarn.lock | 8 +- 10 files changed, 960 insertions(+), 6 deletions(-) create mode 100644 assets/vue/components/lists/AddSubscribersModal.vue create mode 100644 assets/vue/components/lists/CreateListModal.vue create mode 100644 assets/vue/components/lists/EditListModal.vue create mode 100644 assets/vue/components/lists/ListDirectory.vue create mode 100644 assets/vue/views/ListsView.vue create mode 100644 src/Controller/ListsController.php diff --git a/assets/router/index.js b/assets/router/index.js index 526b664..14d1842 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -1,12 +1,14 @@ import { createRouter, createWebHistory } from 'vue-router'; import DashboardView from '../vue/views/DashboardView.vue' import SubscribersView from '../vue/views/SubscribersView.vue' +import ListsView from '../vue/views/ListsView.vue' export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'dashboard', component: DashboardView, meta: { title: 'Dashboard' } }, { path: '/subscribers', name: 'subscribers', component: SubscribersView, meta: { title: 'Subscribers' } }, + { path: '/lists', name: 'lists', component: ListsView, meta: { title: 'Lists' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/api.js b/assets/vue/api.js index b61c721..3ef3307 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,4 +1,4 @@ -import { Client, SubscribersClient } from '@tatevikgr/rest-api-client'; +import {Client, ListClient, SubscribersClient, SubscriptionClient} from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; @@ -15,4 +15,7 @@ if (apiToken) { } export const subscribersClient = new SubscribersClient(client); +export const listClient = new ListClient(client); +export const subscriptionClient = new SubscriptionClient(client); + export default client; diff --git a/assets/vue/components/lists/AddSubscribersModal.vue b/assets/vue/components/lists/AddSubscribersModal.vue new file mode 100644 index 0000000..8c26b0c --- /dev/null +++ b/assets/vue/components/lists/AddSubscribersModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/assets/vue/components/lists/CreateListModal.vue b/assets/vue/components/lists/CreateListModal.vue new file mode 100644 index 0000000..37399ca --- /dev/null +++ b/assets/vue/components/lists/CreateListModal.vue @@ -0,0 +1,181 @@ + + + diff --git a/assets/vue/components/lists/EditListModal.vue b/assets/vue/components/lists/EditListModal.vue new file mode 100644 index 0000000..43668ef --- /dev/null +++ b/assets/vue/components/lists/EditListModal.vue @@ -0,0 +1,244 @@ + + + diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue new file mode 100644 index 0000000..afbb69d --- /dev/null +++ b/assets/vue/components/lists/ListDirectory.vue @@ -0,0 +1,312 @@ + + + diff --git a/assets/vue/views/ListsView.vue b/assets/vue/views/ListsView.vue new file mode 100644 index 0000000..dd66bc4 --- /dev/null +++ b/assets/vue/views/ListsView.vue @@ -0,0 +1,12 @@ + + + diff --git a/package.json b/package.json index 7b5b04a..58b4d76 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "webpack-notifier": "^1.15.0" }, "dependencies": { - "@tatevikgr/rest-api-client": "^1.0.2", + "@tatevikgr/rest-api-client": "^1.2.0", "apexcharts": "^5.10.4", "vue": "^3.5.16", "vue-router": "4", diff --git a/src/Controller/ListsController.php b/src/Controller/ListsController.php new file mode 100644 index 0000000..783efa9 --- /dev/null +++ b/src/Controller/ListsController.php @@ -0,0 +1,35 @@ +isXmlHttpRequest() && $request->headers->get('Accept') !== 'application/json') { + return $this->render('spa.html.twig', [ + 'page' => 'Lists', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } + $initialData = $this->listClient->getLists(); + + return $this->json($initialData); + } +} diff --git a/yarn.lock b/yarn.lock index c3bd267..edab6a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1089,10 +1089,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.0.2.tgz#d78c28a35a037fb3bfdf3ebe6d3e46163498be89" - integrity sha512-b/pu0LEt/p3TTDmsDIAWU95rb60weF7wGK1iuYOd+BNPCS9sineADQQ90z83C+vviU+DoEGSUqa3Uhr/jJwJLw== +"@tatevikgr/rest-api-client@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.2.0.tgz#bca4e74d8e726da578a290a1cace5145aeb6d22b" + integrity sha512-ScYe3PY11iSnplaF0mKuyK4B6nzA6GT2yA4rMyAvezWxAjv2Znn8kbqzbGnDE+jMvhRo3FcutKFnVtB5cr698g== dependencies: axios "^1.6.0" From dae2f1a38fb0acee9387dfcb5ebb67e6522a66bd Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 25 Mar 2026 11:29:47 +0400 Subject: [PATCH 2/6] Fix edit position parsing and validation --- assets/vue/components/lists/CreateListModal.vue | 4 +--- assets/vue/components/lists/EditListModal.vue | 10 ++++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/assets/vue/components/lists/CreateListModal.vue b/assets/vue/components/lists/CreateListModal.vue index 37399ca..eaf3b06 100644 --- a/assets/vue/components/lists/CreateListModal.vue +++ b/assets/vue/components/lists/CreateListModal.vue @@ -149,9 +149,7 @@ const submitCreateList = async () => { return } - const parsedPosition = createForm.value.listPosition === '' - ? null - : Number(createForm.value.listPosition) + const parsedPosition = createForm.value.listPosition === '' ? null : Number(createForm.value.listPosition) if (parsedPosition !== null && (!Number.isInteger(parsedPosition) || parsedPosition < 0)) { createError.value = 'List Position must be a whole number greater than or equal to 0.' diff --git a/assets/vue/components/lists/EditListModal.vue b/assets/vue/components/lists/EditListModal.vue index 43668ef..640da9d 100644 --- a/assets/vue/components/lists/EditListModal.vue +++ b/assets/vue/components/lists/EditListModal.vue @@ -207,11 +207,13 @@ const submitEditList = async () => { editError.value = 'Name is required.' return } + const rawPosition = editForm.value.listPosition - const parsedPosition = - editForm.value.listPosition === '' - ? null - : Number(editForm.value.listPosition) + let parsedPosition = null + + if (rawPosition != null && String(rawPosition).trim() !== '' && String(rawPosition).trim().toLowerCase() !== 'null') { + parsedPosition = Number(rawPosition) + } if (parsedPosition !== null && (!Number.isInteger(parsedPosition) || parsedPosition < 0)) { editError.value = 'List Position must be a whole number greater than or equal to 0.' From 10480a97aa4773abbe75b8c863ad1908ef018f78 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 25 Mar 2026 11:44:57 +0400 Subject: [PATCH 3/6] List subscribers view component --- assets/router/index.js | 2 + assets/vue/components/lists/ListDirectory.vue | 16 +- assets/vue/views/ListSubscribersView.vue | 535 ++++++++++++++++++ src/Controller/ListsController.php | 10 + 4 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 assets/vue/views/ListSubscribersView.vue diff --git a/assets/router/index.js b/assets/router/index.js index 14d1842..231642c 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import DashboardView from '../vue/views/DashboardView.vue' import SubscribersView from '../vue/views/SubscribersView.vue' import ListsView from '../vue/views/ListsView.vue' +import ListSubscribersView from '../vue/views/ListSubscribersView.vue' export const router = createRouter({ history: createWebHistory(), @@ -9,6 +10,7 @@ export const router = createRouter({ { path: '/', name: 'dashboard', component: DashboardView, meta: { title: 'Dashboard' } }, { path: '/subscribers', name: 'subscribers', component: SubscribersView, meta: { title: 'Subscribers' } }, { path: '/lists', name: 'lists', component: ListsView, meta: { title: 'Lists' } }, + { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue index afbb69d..3d55efb 100644 --- a/assets/vue/components/lists/ListDirectory.vue +++ b/assets/vue/components/lists/ListDirectory.vue @@ -196,6 +196,7 @@ diff --git a/src/Controller/ListsController.php b/src/Controller/ListsController.php index 783efa9..9bf6983 100644 --- a/src/Controller/ListsController.php +++ b/src/Controller/ListsController.php @@ -32,4 +32,14 @@ public function index(Request $request): JsonResponse|Response return $this->json($initialData); } + + #[Route('/{listId}/subscribers', name: 'list_subscribers', methods: ['GET'])] + public function view(Request $request, int $listId): JsonResponse|Response + { + return $this->render('spa.html.twig', [ + 'page' => 'List Subscribers', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } } From a65992e32a0cdecf20b926bd4a98e1491bce8324 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 25 Mar 2026 12:53:14 +0400 Subject: [PATCH 4/6] Export subscribers panel --- assets/vue/api.js | 3 +- .../lists/ListSubscribersExportPanel.vue | 250 ++++++++++++++++++ .../subscribers/SubscriberDirectory.vue | 3 + .../subscribers/SubscribersTable.vue | 64 ----- assets/vue/views/ListSubscribersView.vue | 4 + package.json | 2 +- src/Controller/SubscribersController.php | 85 +++--- yarn.lock | 8 +- 8 files changed, 297 insertions(+), 122 deletions(-) create mode 100644 assets/vue/components/lists/ListSubscribersExportPanel.vue delete mode 100644 assets/vue/components/subscribers/SubscribersTable.vue diff --git a/assets/vue/api.js b/assets/vue/api.js index 3ef3307..95c3cbc 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,4 +1,4 @@ -import {Client, ListClient, SubscribersClient, SubscriptionClient} from '@tatevikgr/rest-api-client'; +import {Client, ListClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient} from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; @@ -17,5 +17,6 @@ if (apiToken) { export const subscribersClient = new SubscribersClient(client); export const listClient = new ListClient(client); export const subscriptionClient = new SubscriptionClient(client); +export const subscriberAttributesClient = new SubscriberAttributesClient(client); export default client; diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.vue b/assets/vue/components/lists/ListSubscribersExportPanel.vue new file mode 100644 index 0000000..b5f3075 --- /dev/null +++ b/assets/vue/components/lists/ListSubscribersExportPanel.vue @@ -0,0 +1,250 @@ + + + diff --git a/assets/vue/components/subscribers/SubscriberDirectory.vue b/assets/vue/components/subscribers/SubscriberDirectory.vue index d7bce38..dcb7f44 100644 --- a/assets/vue/components/subscribers/SubscriberDirectory.vue +++ b/assets/vue/components/subscribers/SubscriberDirectory.vue @@ -83,6 +83,8 @@ + + diff --git a/assets/vue/views/ListSubscribersView.vue b/assets/vue/views/ListSubscribersView.vue index 94fe499..1a8bb3b 100644 --- a/assets/vue/views/ListSubscribersView.vue +++ b/assets/vue/views/ListSubscribersView.vue @@ -245,6 +245,9 @@ + + + @@ -253,6 +256,7 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue' import { useRoute } from 'vue-router' import AdminLayout from '../layouts/AdminLayout.vue' +import ListSubscribersExportPanel from '../components/lists/ListSubscribersExportPanel.vue' import client, { subscriptionClient } from '../api' const route = useRoute() diff --git a/package.json b/package.json index 58b4d76..ffb1684 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "webpack-notifier": "^1.15.0" }, "dependencies": { - "@tatevikgr/rest-api-client": "^1.2.0", + "@tatevikgr/rest-api-client": "^1.3.0", "apexcharts": "^5.10.4", "vue": "^3.5.16", "vue-router": "4", diff --git a/src/Controller/SubscribersController.php b/src/Controller/SubscribersController.php index 9eaccb8..a6238ec 100644 --- a/src/Controller/SubscribersController.php +++ b/src/Controller/SubscribersController.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use PhpList\RestApiClient\Endpoint\SubscribersClient; use PhpList\RestApiClient\Entity\Subscriber; +use PhpList\RestApiClient\Request\Subscriber\ExportSubscriberRequest; use PhpList\RestApiClient\Request\Subscriber\SubscribersFilterRequest; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -92,63 +93,43 @@ public function index(Request $request): JsonResponse|Response #[Route('/export', name: 'export', methods: ['GET'])] public function export(Request $request): Response { - $filter = new SubscribersFilterRequest( - isConfirmed: $request->query->has('confirmed') ? true : - ($request->query->has('unconfirmed') ? false : null), - isBlacklisted: $request->query->has('blacklisted') ? true : - ($request->query->has('non-blacklisted') ? false : null), - findColumn: $request->query->get('findColumn'), - findValue: $request->query->get('findValue'), + $columns = array_values( + array_filter( + $request->query->all('columns'), + static fn (mixed $value): bool => is_string($value) && $value !== '' + ) ); - $collection = $this->subscribersClient->getSubscribers($filter, 0, $request->query->getInt('limit')); - $exportData = $collection->items; - if (empty($exportData)) { - return new Response('No subscribers to export.', Response::HTTP_NOT_FOUND); - } - $handle = fopen('php://temp', 'r+'); - - $headers = [ - 'id', - 'email', - 'createdAt', - 'confirmed', - 'blacklisted', - 'bounceCount', - 'uniqueId', - 'htmlEmail', - 'disabled', - 'lists', - ]; - fputcsv($handle, $headers); - - foreach ($exportData as $data) { - $row = [ - 'id' => $data->id, - 'email' => $data->email, - 'createdAt' => (new DateTimeImmutable($data->createdAt))->format('Y-m-d H:i:s'), - 'confirmed' => $data->confirmed, - 'blacklisted' => $data->blacklisted, - 'bounceCount' => $data->bounceCount, - 'uniqueId' => $data->uniqueId, - 'htmlEmail' => $data->htmlEmail, - 'disabled' => $data->disabled, - 'lists' => implode('|', array_map(fn($list) => $list['name'], $data->subscribedLists)), - ]; - - fputcsv($handle, $row); + $defaultRequest = new ExportSubscriberRequest(); + + $exportRequest = new ExportSubscriberRequest( + dateType: (string) $request->query->get('date_type', 'any'), + listId: $request->query->has('list_id') ? $request->query->getInt('list_id') : null, + dateFrom: $request->query->get('date_from') ?: null, + dateTo: $request->query->get('date_to') ?: null, + columns: $columns === [] ? $defaultRequest->columns : $columns + ); + + $upstreamResponse = $this->subscribersClient->exportSubscribers($exportRequest); + + $content = (string) $upstreamResponse->getBody(); + + $contentType = $upstreamResponse->getHeaderLine('Content-Type'); + if ($contentType === '') { + $contentType = 'text/csv; charset=UTF-8'; } - rewind($handle); - $csvContent = stream_get_contents($handle); - fclose($handle); + $contentDisposition = $upstreamResponse->getHeaderLine('Content-Disposition'); + if ($contentDisposition === '') { + $contentDisposition = sprintf( + 'attachment; filename="subscribers_export_%s.csv"', + date('Y-m-d_H-i-s') + ); + } - $response = new Response($csvContent); - $response->headers->set('Content-Type', 'text/csv'); - $response->headers->set( - 'Content-Disposition', - 'attachment; filename="subscribers_export_' . date('Y-m-d_H-i-s') . '.csv"' - ); + $response = new Response($content); + $response->headers->set('Content-Type', $contentType); + $response->headers->set('Content-Disposition', $contentDisposition); return $response; } diff --git a/yarn.lock b/yarn.lock index edab6a8..475cf12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1089,10 +1089,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.2.0.tgz#bca4e74d8e726da578a290a1cace5145aeb6d22b" - integrity sha512-ScYe3PY11iSnplaF0mKuyK4B6nzA6GT2yA4rMyAvezWxAjv2Znn8kbqzbGnDE+jMvhRo3FcutKFnVtB5cr698g== +"@tatevikgr/rest-api-client@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.3.0.tgz#6b0aaf903539debf12fb6d91771d043d45210eb3" + integrity sha512-53kibnzJYiJiTmlqVF5eRltM3vdbqlp26nHV3FQRTG6rkSgKAix4yLaUy6lU4BpB0WuT0Rn7NRf5H3t37Sl/sg== dependencies: axios "^1.6.0" From e6dff7d9349cb29a6c9fbc8423cc7eff80e11969 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 26 Mar 2026 10:57:18 +0400 Subject: [PATCH 5/6] After review 0 --- .../components/lists/AddSubscribersModal.vue | 83 +++--- .../vue/components/lists/CreateListModal.vue | 148 ++++++----- assets/vue/components/lists/EditListModal.vue | 174 ++++++------- assets/vue/components/lists/ListDirectory.vue | 69 ++++- .../lists/ListSubscribersExportPanel.vue | 31 ++- assets/vue/views/ListSubscribersView.vue | 241 ++++++++++-------- src/Controller/ListsController.php | 4 +- src/Controller/SubscribersController.php | 11 +- 8 files changed, 417 insertions(+), 344 deletions(-) diff --git a/assets/vue/components/lists/AddSubscribersModal.vue b/assets/vue/components/lists/AddSubscribersModal.vue index 8c26b0c..e62f20d 100644 --- a/assets/vue/components/lists/AddSubscribersModal.vue +++ b/assets/vue/components/lists/AddSubscribersModal.vue @@ -6,59 +6,47 @@ role="dialog" aria-modal="true" > - - -
-
-
-

- Add subscribers -

- - -
- -
-
- - -

- Enter one email per line, or separate multiple emails with commas. -

+ + +
+ +
+
+

+ Add subscribers +

+ +
-

- {{ addSubsError }} -

- -
+
+ + +

+ Enter one email per line, or separate multiple emails with commas. +

+
+ +

+ {{ addSubsError }} +

+
-
+
@@ -71,6 +59,7 @@ Cancel
+
diff --git a/assets/vue/components/lists/CreateListModal.vue b/assets/vue/components/lists/CreateListModal.vue index eaf3b06..dcd58ca 100644 --- a/assets/vue/components/lists/CreateListModal.vue +++ b/assets/vue/components/lists/CreateListModal.vue @@ -7,86 +7,84 @@ aria-modal="true" > - -
-
-
-

- Create New List -

- -
- -
-
- - -
- -
- - -
- -
- - + +
+
+
+

+ Create New List +

+
-
- - -
- -

{{ createError }}

- -
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

{{ createError }}

+
-
- - + {{ creatingList ? 'Creating...' : 'Create' }} + + +
-
+
diff --git a/assets/vue/components/lists/EditListModal.vue b/assets/vue/components/lists/EditListModal.vue index 640da9d..41d5981 100644 --- a/assets/vue/components/lists/EditListModal.vue +++ b/assets/vue/components/lists/EditListModal.vue @@ -1,137 +1,124 @@ @@ -164,13 +151,12 @@ const editForm = ref({ }) const fillEditForm = () => { + const sourcePosition = props.list?.listPosition ?? props.list?.list_position + editForm.value = { name: props.list?.name || '', public: !!props.list?.public, - listPosition: - props.list?.listPosition !== null && props.list?.list_position !== undefined - ? String(props.list.list_position) - : null, + listPosition: sourcePosition == null ? '' : String(sourcePosition), description: props.list?.description || '', category: props.list?.category || '', rssFeed: props.list?.rss_feed || '', @@ -207,12 +193,10 @@ const submitEditList = async () => { editError.value = 'Name is required.' return } - const rawPosition = editForm.value.listPosition let parsedPosition = null - - if (rawPosition != null && String(rawPosition).trim() !== '' && String(rawPosition).trim().toLowerCase() !== 'null') { - parsedPosition = Number(rawPosition) + if (String(editForm.value.listPosition).trim() !== '') { + parsedPosition = Number(editForm.value.listPosition) } if (parsedPosition !== null && (!Number.isInteger(parsedPosition) || parsedPosition < 0)) { diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue index 3d55efb..5c94021 100644 --- a/assets/vue/components/lists/ListDirectory.vue +++ b/assets/vue/components/lists/ListDirectory.vue @@ -92,7 +92,24 @@ - + + + No mailing lists found. + + + + + Loading mailing lists... + + + + + + {{ loadError }} + + + + No mailing lists found. @@ -164,7 +181,21 @@
+ Loading mailing lists... +
+ +
+ {{ loadError }} +
+ +
No mailing lists found. @@ -201,14 +232,14 @@ import BaseIcon from '../base/BaseIcon.vue' import CreateListModal from './CreateListModal.vue' import EditListModal from './EditListModal.vue' import AddSubscribersModal from './AddSubscribersModal.vue' - import { listClient } from '../../api' const router = useRouter() const mailingLists = ref([]) +const isLoading = ref(false) +const loadError = ref('') const isCreateModalOpen = ref(false) - const selectedList = ref(null) const isEditModalOpen = ref(false) const isAddSubscribersModalOpen = ref(false) @@ -216,6 +247,9 @@ const isAddSubscribersModalOpen = ref(false) const fetchMailingLists = async () => { const url = new URL('/lists', window.location.origin) + isLoading.value = true + loadError.value = '' + try { const response = await fetch(url, { headers: { @@ -224,10 +258,27 @@ const fetchMailingLists = async () => { } }) + const contentType = response.headers.get('content-type') || '' + if (response.status === 401) { - const data = await response.json() - window.location.href = data.redirect - return + if (contentType.includes('application/json')) { + const data = await response.json() + + if (data?.redirect) { + window.location.href = data.redirect + return + } + } + + throw new Error('Authentication required. Please sign in again.') + } + + if (!response.ok) { + throw new Error(`Failed to load mailing lists (${response.status}).`) + } + + if (!contentType.includes('application/json')) { + throw new Error('Server returned an unexpected response format.') } const data = await response.json() @@ -235,6 +286,9 @@ const fetchMailingLists = async () => { } catch (error) { console.error('Failed to fetch mailing lists:', error) mailingLists.value = [] + loadError.value = error?.message || 'Failed to load mailing lists.' + } finally { + isLoading.value = false } } @@ -295,6 +349,7 @@ const handleListUpdated = async () => { } const handleStartCampaign = (list) => emit('start-campaign', list) + const handleViewMembers = (list) => { if (!list?.id) return diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.vue b/assets/vue/components/lists/ListSubscribersExportPanel.vue index b5f3075..9bb4213 100644 --- a/assets/vue/components/lists/ListSubscribersExportPanel.vue +++ b/assets/vue/components/lists/ListSubscribersExportPanel.vue @@ -119,9 +119,25 @@ import client, { subscriberAttributesClient } from '../../api' onMounted(async () => { try { - const queryParams = { limit: 5 }; - const data = await client.get('attributes', queryParams) - const dynamicColumns = data.items.map(attr => ({ + const allAttributes = [] + let offset = 0 + const limit = 100 + let hasMore = true + + while (hasMore) { + const data = await client.get('attributes', { limit, offset }) + const items = Array.isArray(data?.items) ? data.items : [] + + allAttributes.push(...items) + + if (items.length < limit) { + hasMore = false + } else { + offset += limit + } + } + + const dynamicColumns = allAttributes.map(attr => ({ value: attr.name, label: capitalizeFirst(attr.name) })) @@ -131,7 +147,9 @@ onMounted(async () => { ...dynamicColumns ] - form.value.columns = columnOptions.value.map(c => c.value) + if (!form.value.columns?.length) { + form.value.columns = columnOptions.value.map(c => c.value) + } } catch (err) { console.error('Failed to load attribute definitions', err) } @@ -186,7 +204,10 @@ const selectAllColumnsCheckbox = ref(null) const usesAnyDate = computed(() => form.value.dateType === 'any') const allColumnsSelected = computed(() => { - return form.value.columns.length === columnOptions.length + return ( + columnOptions.value.length > 0 && + form.value.columns.length === columnOptions.value.length + ) }) const someColumnsSelected = computed(() => { diff --git a/assets/vue/views/ListSubscribersView.vue b/assets/vue/views/ListSubscribersView.vue index 1a8bb3b..f51b621 100644 --- a/assets/vue/views/ListSubscribersView.vue +++ b/assets/vue/views/ListSubscribersView.vue @@ -11,8 +11,8 @@
Back to Lists @@ -20,13 +20,14 @@
-
+

Subscribers

- @@ -92,73 +89,73 @@
- - - - - - - - + > + + + + + + + - - - + + + - - - + + + - - - - - + + + - - + - - - - - + > + Delete + + + + + + + @@ -169,44 +166,44 @@

{{ subscriber.email }}

{{ subscriber.confirmed ? 'Confirmed' : 'Unconfirmed' }}
-

#{{ subscriber.id }} · {{ formatDate(subscriber.createdAt) }}

+

#{{ subscriber.id }} · {{formatDate(subscriber.createdAt) }}

@@ -215,30 +212,31 @@
No subscribers for this filter.
-
+
Showing {{ filteredSubscribers.length }} on page · Total: {{ total }}
@@ -246,18 +244,18 @@
- +