diff --git a/assets/router/index.js b/assets/router/index.js index 526b664..231642c 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -1,12 +1,16 @@ 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(), 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: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/api.js b/assets/vue/api.js index b61c721..95c3cbc 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, SubscriberAttributesClient} from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; @@ -15,4 +15,8 @@ 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/AddSubscribersModal.vue b/assets/vue/components/lists/AddSubscribersModal.vue new file mode 100644 index 0000000..e62f20d --- /dev/null +++ b/assets/vue/components/lists/AddSubscribersModal.vue @@ -0,0 +1,154 @@ + + + diff --git a/assets/vue/components/lists/CreateListModal.vue b/assets/vue/components/lists/CreateListModal.vue new file mode 100644 index 0000000..dcd58ca --- /dev/null +++ b/assets/vue/components/lists/CreateListModal.vue @@ -0,0 +1,177 @@ + + + diff --git a/assets/vue/components/lists/EditListModal.vue b/assets/vue/components/lists/EditListModal.vue new file mode 100644 index 0000000..41d5981 --- /dev/null +++ b/assets/vue/components/lists/EditListModal.vue @@ -0,0 +1,230 @@ + + + diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue new file mode 100644 index 0000000..5c94021 --- /dev/null +++ b/assets/vue/components/lists/ListDirectory.vue @@ -0,0 +1,377 @@ + + + diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.vue b/assets/vue/components/lists/ListSubscribersExportPanel.vue new file mode 100644 index 0000000..9bb4213 --- /dev/null +++ b/assets/vue/components/lists/ListSubscribersExportPanel.vue @@ -0,0 +1,271 @@ + + + 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 new file mode 100644 index 0000000..f51b621 --- /dev/null +++ b/assets/vue/views/ListSubscribersView.vue @@ -0,0 +1,562 @@ + + + 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..ffb1684 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.3.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..f52d49d --- /dev/null +++ b/src/Controller/ListsController.php @@ -0,0 +1,47 @@ +headers->get('Accept', ''); + $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); + if (! $wantsJson) { + 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); + } + + #[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'), + ]); + } +} diff --git a/src/Controller/SubscribersController.php b/src/Controller/SubscribersController.php index 9eaccb8..5284147 100644 --- a/src/Controller/SubscribersController.php +++ b/src/Controller/SubscribersController.php @@ -7,11 +7,14 @@ 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 PhpList\RestApiClient\Response\Subscribers\SubscriberCollection; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; #[Route('/subscribers', name: 'subscriber_')] @@ -28,7 +31,9 @@ public function __construct(private readonly SubscribersClient $subscribersClien #[Route('/', name: 'list', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { - if (! $request->isXmlHttpRequest() && $request->headers->get('Accept') !== 'application/json') { + $accept = (string) $request->headers->get('Accept', ''); + $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); + if (! $wantsJson) { return $this->render('spa.html.twig', [ 'page' => 'Subscribers', 'api_token' => $request->getSession()->get('auth_token'), @@ -36,8 +41,7 @@ public function index(Request $request): JsonResponse|Response ]); } - $afterId = (int) $request->query->get('after_id'); - $limit = max(1, (int) $request->query->get('limit', 10)); + $afterId = $request->query->has('after_id') ? $request->query->getInt('after_id') : null; $filter = new SubscribersFilterRequest( isConfirmed: $request->query->has('confirmed') ? true : @@ -48,7 +52,7 @@ public function index(Request $request): JsonResponse|Response findValue: $request->query->get('findValue'), ); - $collection = $this->subscribersClient->getSubscribers($filter, $afterId, $limit); + $collection = $this->subscribersClient->getSubscribers($filter, $afterId, 10); $history = $request->getSession()->get('subscribers_history', []); if (!in_array($afterId, $history, true)) { @@ -64,7 +68,60 @@ public function index(Request $request): JsonResponse|Response $prevId = $history[$index - 1]; } - $initialData = [ + return $this->json($this->normalize($collection, $prevId, $afterId)); + } + + /** + * @SuppressWarnings("CyclomaticComplexity") + * @SuppressWarnings("NPathComplexity") + */ + #[Route('/export', name: 'export', methods: ['GET'])] + public function export(Request $request): Response + { + $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: array_values(array_filter($request->query->all('columns'))) + ); + + $upstreamResponse = $this->subscribersClient->exportSubscribers($exportRequest); + + $contentType = $upstreamResponse->getHeaderLine('Content-Type'); + if ($contentType === '') { + $contentType = 'text/csv; charset=UTF-8'; + } + + $contentDisposition = $upstreamResponse->getHeaderLine('Content-Disposition'); + if ($contentDisposition === '') { + $contentDisposition = sprintf( + 'attachment; filename="subscribers_export_%s.csv"', + date('Y-m-d_H-i-s') + ); + } + + $body = $upstreamResponse->getBody(); + $response = new StreamedResponse( + static function () use ($body): void { + if ($body->isSeekable()) { + $body->rewind(); + } + while (! $body->eof()) { + echo $body->read(8192); + } + }, + $upstreamResponse->getStatusCode() + ); + $response->headers->set('Content-Type', $contentType); + $response->headers->set('Content-Disposition', $contentDisposition); + + return $response; + } + + private function normalize(SubscriberCollection $collection, ?int $prevId, ?int $afterId): array + { + return [ 'items' => array_map(static function (Subscriber $subscriber) { return [ 'id' => $subscriber->id, @@ -82,74 +139,8 @@ public function index(Request $request): JsonResponse|Response 'hasMore' => $collection->pagination->hasMore , 'total' => $collection->pagination->total, 'prevId' => $prevId, - 'isFirstPage' => $afterId === 0, + 'isFirstPage' => $afterId === null, ], ]; - - return $this->json($initialData); - } - - #[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'), - ); - - $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); - } - - rewind($handle); - $csvContent = stream_get_contents($handle); - fclose($handle); - - $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"' - ); - - return $response; } } diff --git a/yarn.lock b/yarn.lock index c3bd267..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.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.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"