Skip to content

Commit f5d7fda

Browse files
committed
Add subscriber modal
1 parent 6fcb278 commit f5d7fda

File tree

4 files changed

+293
-4
lines changed

4 files changed

+293
-4
lines changed

assets/vue/components/subscribers/SubscriberDirectory.vue

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@
4040
<div class="px-6 py-4 bg-slate-50/50 border-b border-slate-200">
4141
<SubscriberFilters @filter-change="handleFilterChange" />
4242
</div>
43-
<SubscriberTable :subscribers="subscribers" />
43+
<SubscriberTable :subscribers="subscribers" @view="openSubscriberModal" />
44+
<SubscriberModal
45+
:is-open="isModalOpen"
46+
:subscriber-id="selectedSubscriberId"
47+
@close="closeSubscriberModal"
48+
@updated="handleSubscriberUpdated"
49+
/>
4450
<div class="p-4 sm:p-6 border-t border-slate-200 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-slate-500">
4551
<div class="text-center sm:text-left">
4652
Showing <span class="font-medium text-slate-900">{{ subscribers.length }}</span> of <span class="font-medium text-slate-900">{{ pagination.total }}</span> subscribers
@@ -69,6 +75,7 @@
6975
import BaseIcon from '../base/BaseIcon.vue'
7076
import SubscriberFilters from './SubscriberFilters.vue'
7177
import SubscriberTable from './SubscriberTable.vue'
78+
import SubscriberModal from './SubscriberModal.vue'
7279
import { inject, ref, onMounted, watch } from 'vue'
7380
import { subscriberFilters } from './subscriberFilters'
7481
@@ -86,6 +93,8 @@ const pagination = ref(initialPagination)
8693
const currentFilter = ref(null)
8794
const searchQuery = ref('')
8895
const searchColumn = ref('email')
96+
const isModalOpen = ref(false)
97+
const selectedSubscriberId = ref(null)
8998
let searchTimeout = null
9099
91100
const searchColumns = [
@@ -190,6 +199,29 @@ const handleSearch = () => {
190199
}, 300)
191200
}
192201
202+
const openSubscriberModal = (id) => {
203+
selectedSubscriberId.value = id
204+
isModalOpen.value = true
205+
}
206+
207+
const closeSubscriberModal = () => {
208+
isModalOpen.value = false
209+
selectedSubscriberId.value = null
210+
}
211+
212+
const handleSubscriberUpdated = (updatedSubscriber) => {
213+
const index = subscribers.value.findIndex(s => s.id === updatedSubscriber.id)
214+
if (index !== -1) {
215+
subscribers.value[index] = {
216+
...subscribers.value[index],
217+
email: updatedSubscriber.email,
218+
confirmed: updatedSubscriber.confirmed,
219+
blacklisted: updatedSubscriber.blacklisted,
220+
listCount: updatedSubscriber.subscribedLists ? updatedSubscriber.subscribedLists.length : subscribers.value[index].listCount
221+
}
222+
}
223+
}
224+
193225
const nextPage = () => {
194226
if (pagination.value.hasMore) {
195227
fetchSubscribers(pagination.value.afterId)
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<template>
2+
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0" aria-labelledby="modal-title" role="dialog" aria-modal="true">
3+
<!-- Backdrop -->
4+
<div class="fixed inset-0 bg-slate-900/50 transition-opacity" aria-hidden="true" @click="close"></div>
5+
6+
<!-- Modal Content -->
7+
<div class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full z-10">
8+
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
9+
<div class="sm:flex sm:items-start">
10+
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
11+
<div class="flex justify-between items-center">
12+
<h3 class="text-lg leading-6 font-medium text-slate-900" id="modal-title">
13+
Subscriber Details
14+
</h3>
15+
<button type="button" class="text-slate-400 hover:text-slate-500" @click="close">
16+
<BaseIcon name="close" class="w-5 h-5" />
17+
</button>
18+
</div>
19+
<div class="mt-4 space-y-4">
20+
<div v-if="loading" class="flex justify-center py-8">
21+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
22+
</div>
23+
<div v-else-if="error" class="text-red-500 text-sm">
24+
{{ error }}
25+
</div>
26+
<form v-else @submit.prevent="save" class="space-y-4">
27+
<div>
28+
<label class="block text-sm font-medium text-slate-700">Email</label>
29+
<input
30+
v-model="formData.email"
31+
type="email"
32+
required
33+
class="mt-1 block w-full border border-slate-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
34+
>
35+
</div>
36+
37+
<div class="flex items-center">
38+
<input
39+
id="confirmed"
40+
v-model="formData.confirmed"
41+
type="checkbox"
42+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded"
43+
>
44+
<label for="confirmed" class="ml-2 block text-sm text-slate-900">
45+
Confirmed
46+
</label>
47+
</div>
48+
49+
<div class="flex items-center">
50+
<input
51+
id="blacklisted"
52+
v-model="formData.blacklisted"
53+
type="checkbox"
54+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded"
55+
>
56+
<label for="blacklisted" class="ml-2 block text-sm text-slate-900">
57+
Blacklisted
58+
</label>
59+
</div>
60+
61+
<div class="flex items-center">
62+
<input
63+
id="htmlEmail"
64+
v-model="formData.htmlEmail"
65+
type="checkbox"
66+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded"
67+
>
68+
<label for="htmlEmail" class="ml-2 block text-sm text-slate-900">
69+
HTML Email
70+
</label>
71+
</div>
72+
73+
<div class="flex items-center">
74+
<input
75+
id="disabled"
76+
v-model="formData.disabled"
77+
type="checkbox"
78+
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded"
79+
>
80+
<label for="disabled" class="ml-2 block text-sm text-slate-900">
81+
Disabled
82+
</label>
83+
</div>
84+
85+
<div v-if="subscriber && subscriber.subscribedLists" class="mt-4">
86+
<label class="block text-sm font-medium text-slate-700 mb-2">Subscribed Lists</label>
87+
<div class="flex flex-wrap gap-2">
88+
<span
89+
v-for="list in subscriber.subscribedLists"
90+
:key="list.id"
91+
class="px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700"
92+
>
93+
{{ list.name }}
94+
</span>
95+
<span v-if="!subscriber.subscribedLists.length" class="text-xs text-slate-500">
96+
No lists
97+
</span>
98+
</div>
99+
</div>
100+
101+
<div v-if="subscriber" class="text-xs text-slate-400 mt-4 pt-4 border-t border-slate-100">
102+
<p>ID: {{ subscriber.id }}</p>
103+
<p>Unique ID: {{ subscriber.uniqueId }}</p>
104+
<p>Created: {{ subscriber.createdAt }}</p>
105+
</div>
106+
</form>
107+
</div>
108+
</div>
109+
</div>
110+
</div>
111+
<div class="bg-slate-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2">
112+
<button
113+
type="button"
114+
:disabled="loading || saving"
115+
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-ext-wf1 text-base font-medium text-white hover:bg-ext-wf3 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:w-auto sm:text-sm disabled:opacity-50"
116+
@click="save"
117+
>
118+
{{ saving ? 'Saving...' : 'Save' }}
119+
</button>
120+
<button
121+
type="button"
122+
class="mt-3 w-full inline-flex justify-center rounded-md border border-slate-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm"
123+
@click="close"
124+
>
125+
Cancel
126+
</button>
127+
</div>
128+
</div>
129+
</div>
130+
</template>
131+
132+
<script setup>
133+
import BaseIcon from '../base/BaseIcon.vue'
134+
import { ref, watch } from 'vue'
135+
136+
const props = defineProps({
137+
isOpen: Boolean,
138+
subscriberId: Number
139+
})
140+
141+
const emit = defineEmits(['close', 'updated'])
142+
143+
const loading = ref(false)
144+
const saving = ref(false)
145+
const error = ref(null)
146+
const subscriber = ref(null)
147+
const formData = ref({
148+
email: '',
149+
confirmed: false,
150+
blacklisted: false,
151+
htmlEmail: false,
152+
disabled: false
153+
})
154+
155+
watch(() => props.isOpen, (newValue) => {
156+
if (newValue && props.subscriberId) {
157+
fetchSubscriberDetails()
158+
}
159+
})
160+
161+
const fetchSubscriberDetails = async () => {
162+
loading.value = true
163+
error.value = null
164+
try {
165+
const response = await fetch(`/subscribers/${props.subscriberId}`, {
166+
headers: {
167+
'Accept': 'application/json',
168+
'X-Requested-With': 'XMLHttpRequest'
169+
}
170+
})
171+
if (!response.ok) throw new Error('Failed to fetch subscriber details')
172+
subscriber.value = await response.json()
173+
174+
// Update formData
175+
formData.value = {
176+
email: subscriber.value.email,
177+
confirmed: !!subscriber.value.confirmed,
178+
blacklisted: !!subscriber.value.blacklisted,
179+
htmlEmail: !!subscriber.value.htmlEmail,
180+
disabled: !!subscriber.value.disabled
181+
}
182+
} catch (err) {
183+
error.value = err.message
184+
} finally {
185+
loading.value = false
186+
}
187+
}
188+
189+
const save = async () => {
190+
saving.value = true
191+
error.value = null
192+
try {
193+
const response = await fetch(`/subscribers/${props.subscriberId}`, {
194+
method: 'PUT',
195+
headers: {
196+
'Content-Type': 'application/json',
197+
'Accept': 'application/json',
198+
'X-Requested-With': 'XMLHttpRequest'
199+
},
200+
body: JSON.stringify(formData.value)
201+
})
202+
203+
if (!response.ok) throw new Error('Failed to update subscriber')
204+
205+
const updatedSubscriber = await response.json()
206+
emit('updated', updatedSubscriber)
207+
close()
208+
} catch (err) {
209+
error.value = err.message
210+
} finally {
211+
saving.value = false
212+
}
213+
}
214+
215+
const close = () => {
216+
emit('close')
217+
}
218+
</script>

assets/vue/components/subscribers/SubscriberTable.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@
4747
{{ subscriber.createdAt }}
4848
</td>
4949
<td class="px-6 py-4 text-right">
50-
<button class="text-slate-400 hover:text-slate-600">
50+
<button
51+
class="text-slate-400 hover:text-slate-600"
52+
@click="emit('view', subscriber.id)"
53+
>
5154
<BaseIcon name="eye" class="w-4 h-4" />
5255
</button>
5356
</td>
@@ -67,6 +70,12 @@
6770
{{ subscriber.email.split('@')[0] }}
6871
</span>
6972
<div class="flex gap-2">
73+
<button
74+
class="text-slate-400 hover:text-slate-600 mr-2"
75+
@click="emit('view', subscriber.id)"
76+
>
77+
<BaseIcon name="eye" class="w-4 h-4" />
78+
</button>
7079
<span
7180
class="px-2.5 py-0.5 rounded-full text-xs font-medium"
7281
:class="subscriber.confirmed ? statusClasses.active : statusClasses.unconfirmed"
@@ -116,6 +125,8 @@ const props = defineProps({
116125
}
117126
})
118127
128+
const emit = defineEmits(['view'])
129+
119130
const formatDate = (dateString, isIso = false) => {
120131
if (!dateString) return '-'
121132
const date = new Date(dateString)

src/Controller/SubscribersController.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
use PhpList\RestApiClient\Endpoint\SubscribersClient;
99
use PhpList\RestApiClient\Entity\Subscriber;
1010
use PhpList\RestApiClient\Request\Subscriber\SubscribersFilterRequest;
11+
use PhpList\RestApiClient\Request\Subscriber\UpdateSubscriberRequest;
1112
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1213
use Symfony\Component\HttpFoundation\JsonResponse;
1314
use Symfony\Component\HttpFoundation\Request;
1415
use Symfony\Component\HttpFoundation\Response;
1516
use Symfony\Component\Routing\Attribute\Route;
1617

18+
#[Route('/subscribers', name: 'subscriber_')]
1719
class SubscribersController extends AbstractController
1820
{
1921
public function __construct(private readonly SubscribersClient $subscribersClient)
@@ -24,7 +26,7 @@ public function __construct(private readonly SubscribersClient $subscribersClien
2426
* @SuppressWarnings("CyclomaticComplexity")
2527
* @SuppressWarnings("NPathComplexity")
2628
*/
27-
#[Route('/subscribers', name: 'subscribers', methods: ['GET'])]
29+
#[Route('/', name: 'list', methods: ['GET'])]
2830
public function index(Request $request): JsonResponse|Response
2931
{
3032
if (! $request->isXmlHttpRequest() && $request->headers->get('Accept') !== 'application/json') {
@@ -84,7 +86,7 @@ public function index(Request $request): JsonResponse|Response
8486
return $this->json($initialData);
8587
}
8688

87-
#[Route('/subscribers/export', name: 'subscribers_export', methods: 'GET')]
89+
#[Route('/export', name: 'export', methods: 'GET')]
8890
public function export(Request $request): Response
8991
{
9092
$filter = new SubscribersFilterRequest(
@@ -136,4 +138,30 @@ public function export(Request $request): Response
136138

137139
return $response;
138140
}
141+
142+
#[Route('/{id}', name: 'details', methods: ['GET'])]
143+
public function getDetails(int $id): JsonResponse
144+
{
145+
$subscriber = $this->subscribersClient->getSubscriber($id);
146+
147+
return $this->json($subscriber);
148+
}
149+
150+
#[Route('/{id}', name: 'update', methods: ['PUT'])]
151+
public function update(int $id, Request $request): JsonResponse
152+
{
153+
$data = json_decode($request->getContent(), true);
154+
155+
$updateRequest = new UpdateSubscriberRequest(
156+
email: $data['email'],
157+
confirmed: $data['confirmed'] ?? false,
158+
blacklisted: $data['blacklisted'] ?? false,
159+
htmlEmail: $data['htmlEmail'] ?? false,
160+
disabled: $data['disabled'] ?? false,
161+
);
162+
163+
$subscriber = $this->subscribersClient->updateSubscriber($id, $updateRequest);
164+
165+
return $this->json($subscriber);
166+
}
139167
}

0 commit comments

Comments
 (0)