Skip to content

Commit cc80955

Browse files
authored
feat(admin-sms): enhance bulk SMS sending interface (#1297)
- Add placeholder instructions for CSV data in message input - Introduce toggle between phone number list input and CSV file upload - Display invalid phone numbers and CSV parsing errors - Show status of sent and failed SMS numbers feat(admin-email): enhance bulk email sending and add image library - Add placeholder instructions for CSV data in message input - Introduce toggle between email list input and CSV file upload - Display invalid emails and CSV parsing errors - Show status of sent and failed email addresses - Add option to show email images feat(admin-email): add email images library component - Display email images with copy link functionality - Fetch email images on component mount - Handle image link copy success and error using notifications
1 parent 68d5ea4 commit cc80955

File tree

3 files changed

+418
-89
lines changed

3 files changed

+418
-89
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!-- EmailImageLibrary.vue -->
2+
<template>
3+
<div class="email-images-library">
4+
<h3>{{ $t('Email Images') }}</h3>
5+
<div class="images-grid">
6+
<div
7+
v-for="image in emailImages"
8+
:key="image.file_key"
9+
class="image-item"
10+
>
11+
<img :src="image.signed_url" alt="" class="image-thumbnail" />
12+
<base-button
13+
variant="outline"
14+
size="small"
15+
@click="copyImageLink(image.signed_url)"
16+
>
17+
{{ $t('Copy Link') }}
18+
</base-button>
19+
</div>
20+
</div>
21+
</div>
22+
</template>
23+
24+
<script setup lang="ts">
25+
import axios from 'axios';
26+
import { useI18n } from 'vue-i18n';
27+
import { useToast } from 'vue-toastification';
28+
import BaseButton from '@/components/BaseButton.vue';
29+
30+
const { t } = useI18n();
31+
const $toasted = useToast();
32+
33+
const emailImages = ref<any[]>([]);
34+
35+
const copyImageLink = (url: string) => {
36+
navigator.clipboard
37+
.writeText(url)
38+
.then(() => {
39+
$toasted.success(t('Image link copied to clipboard.'));
40+
})
41+
.catch(() => {
42+
$toasted.error(t('Failed to copy image link.'));
43+
});
44+
};
45+
46+
// Fetch email images on component mount
47+
onMounted(async () => {
48+
try {
49+
const response = await axios.get('admins/email-images');
50+
emailImages.value = response.data;
51+
} catch {
52+
$toasted.error(t('Failed to load email images.'));
53+
}
54+
});
55+
</script>
56+
57+
<style scoped>
58+
.email-images-library {
59+
padding: 1rem;
60+
max-height: 80vh;
61+
overflow-y: auto;
62+
}
63+
.email-images-library h3 {
64+
font-size: 1.25rem;
65+
margin-bottom: 0.5rem;
66+
}
67+
.images-grid {
68+
display: grid;
69+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
70+
gap: 1rem;
71+
}
72+
.image-item {
73+
border: 1px solid #ddd;
74+
padding: 0.5rem;
75+
text-align: center;
76+
}
77+
.image-thumbnail {
78+
max-height: 100px;
79+
object-fit: contain;
80+
margin-bottom: 0.5rem;
81+
}
82+
</style>

src/pages/admin/AdminSendBulkEmail.vue

Lines changed: 171 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,63 @@
99
/>
1010
<base-input
1111
v-model="htmlMessage"
12-
:placeholder="$t('Enter email message')"
12+
:placeholder="
13+
$t('Enter email message (use {{placeholder}} for CSV data)')
14+
"
1315
text-area
1416
rows="5"
1517
class="mb-2"
1618
/>
17-
<base-input
18-
v-model="emailList"
19-
:placeholder="$t('Enter recipient emails, one per line')"
20-
text-area
21-
rows="5"
22-
class="mb-2"
23-
/>
24-
<!-- Display invalid emails if any -->
25-
<div v-if="invalidEmailsList.length > 0" class="mt-2">
26-
<p class="text-red-500">
27-
{{ $t('The following emails were invalid and have been removed:') }}
28-
</p>
29-
<ul class="list-disc list-inside text-red-500">
30-
<li v-for="email in invalidEmailsList" :key="email">{{ email }}</li>
31-
</ul>
19+
20+
<!-- Toggle between Email List and CSV File -->
21+
<div class="mb-2">
22+
<label>
23+
<input v-model="inputMethod" type="radio" value="emailList" />
24+
{{ $t('Enter Email List') }}
25+
</label>
26+
<label class="ml-4">
27+
<input v-model="inputMethod" type="radio" value="csvFile" />
28+
{{ $t('Upload CSV File') }}
29+
</label>
30+
</div>
31+
32+
<!-- Email List Input -->
33+
<div v-if="inputMethod === 'emailList'">
34+
<base-input
35+
v-model="emailList"
36+
:placeholder="$t('Enter recipient emails, one per line')"
37+
text-area
38+
rows="5"
39+
class="mb-2"
40+
/>
41+
<!-- Display invalid emails if any -->
42+
<div v-if="invalidEmailsList.length > 0" class="mt-2">
43+
<p class="text-red-500">
44+
{{ $t('The following emails were invalid and have been removed:') }}
45+
</p>
46+
<ul class="list-disc list-inside text-red-500">
47+
<li v-for="email in invalidEmailsList" :key="email">{{ email }}</li>
48+
</ul>
49+
</div>
50+
</div>
51+
52+
<!-- CSV File Upload -->
53+
<div v-else-if="inputMethod === 'csvFile'">
54+
<input
55+
type="file"
56+
accept=".csv"
57+
class="mb-2"
58+
@change="handleFileUpload"
59+
/>
60+
<!-- Display CSV parsing errors if any -->
61+
<div v-if="csvErrors.length > 0" class="mt-2 text-red-500">
62+
<p>{{ $t('Errors in CSV file:') }}</p>
63+
<ul class="list-disc list-inside">
64+
<li v-for="error in csvErrors" :key="error">{{ error }}</li>
65+
</ul>
66+
</div>
3267
</div>
68+
3369
<div class="flex gap-2">
3470
<base-button
3571
:action="sendEmails"
@@ -47,14 +83,42 @@
4783
:text="$t('actions.show_preview')"
4884
:alt="$t('actions.show_preview')"
4985
/>
86+
<base-button
87+
type="bare"
88+
class="px-2 py-1 mt-4"
89+
variant="outline"
90+
:action="showAdminEmailAssets"
91+
:text="$t('~~Show Email Images')"
92+
:alt="$t('~~Show Email Images')"
93+
/>
5094
</div>
95+
96+
<!-- Task Status Display -->
5197
<div v-if="taskStatus" class="mt-4">
5298
<p>{{ $t('Task Status') }}: {{ taskStatus.state }}</p>
5399
<div v-if="taskStatus.state === 'SUCCESS'">
54-
<p>{{ $t('Emails sent successfully.') }}</p>
100+
<p>{{ $t('Email sending process completed.') }}</p>
101+
<!-- Display sent emails -->
102+
<div v-if="sentEmails.length > 0" class="mt-2">
103+
<p class="text-green-600">{{ $t('Successfully sent to:') }}</p>
104+
<ul class="list-disc list-inside text-green-600">
105+
<li v-for="email in sentEmails" :key="email">{{ email }}</li>
106+
</ul>
107+
</div>
108+
<!-- Display unsuccessful emails -->
109+
<div v-if="unsuccessfulEmails.length > 0" class="mt-2">
110+
<p class="text-red-500">{{ $t('Failed to send to:') }}</p>
111+
<ul class="list-disc list-inside text-red-500">
112+
<li v-for="item in unsuccessfulEmails" :key="item.email">
113+
{{ item.email }}: {{ item.error }}
114+
</li>
115+
</ul>
116+
</div>
55117
</div>
56118
<div v-else-if="taskStatus.state === 'FAILURE'">
57119
<p>{{ $t('Failed to send emails.') }}</p>
120+
<!-- Display error details if available -->
121+
<p v-if="taskStatus.result">{{ taskStatus.result }}</p>
58122
</div>
59123
<div v-else>
60124
<p>{{ $t('Processing...') }}</p>
@@ -73,83 +137,116 @@ import CmsViewer from '@/components/cms/CmsViewer.vue';
73137
import useDialogs from '@/hooks/useDialogs';
74138
import { ref } from 'vue';
75139
import { getErrorMessage } from '@/utils/errors';
140+
import AdminEmailAssets from '@/components/admin/AdminEmailAssets.vue';
76141
77142
const { t } = useI18n();
78143
const $toasted = useToast();
79144
const { component } = useDialogs();
80145
81146
const subject = ref('');
82147
const htmlMessage = ref('');
148+
const inputMethod = ref('emailList'); // 'emailList' or 'csvFile'
83149
const emailList = ref('');
150+
const csvFile = ref<File | null>(null);
84151
const invalidEmailsList = ref<string[]>([]);
85-
const taskStatus = ref(null);
152+
const csvErrors = ref<string[]>([]);
153+
const taskStatus = ref<any>(null);
154+
const sentEmails = ref<string[]>([]);
155+
const unsuccessfulEmails = ref<{ email: string; error: string }[]>([]);
86156
let statusInterval = null;
87-
const editor = ref<HTMLElement | null>(null);
157+
158+
const handleFileUpload = (event: Event) => {
159+
const target = event.target as HTMLInputElement;
160+
csvFile.value =
161+
target.files && target.files.length > 0 ? target.files[0] : null;
162+
};
163+
164+
const validateEmail = (email: string) => {
165+
const emailRegex =
166+
/^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@(([^\s"(),.:;<>@[\\\]]+\.)+[^\s"(),.:;<>@[\\\]]{2,})$/i;
167+
return emailRegex.test(email);
168+
};
88169
89170
const sendEmails = async () => {
90-
if (!subject.value || !htmlMessage.value || !emailList.value.trim()) {
91-
$toasted.error(
92-
t('Please fill all fields and add at least one recipient email.'),
93-
);
171+
if (!subject.value || !htmlMessage.value) {
172+
$toasted.error(t('Please fill all fields and add at least one recipient.'));
94173
return;
95174
}
96175
97-
const emails = emailList.value
98-
.split('\n')
99-
.map((email) => email.trim())
100-
.filter((email) => email !== '');
176+
const formData = new FormData();
177+
formData.append('subject', subject.value);
178+
formData.append('html_message', htmlMessage.value);
101179
102-
// Validate emails
103-
const invalidEmails = emails.filter((email) => !validateEmail(email));
104-
const validEmails = emails.filter((email) => validateEmail(email));
180+
if (inputMethod.value === 'emailList') {
181+
if (!emailList.value.trim()) {
182+
$toasted.error(t('Please enter at least one recipient email.'));
183+
return;
184+
}
185+
186+
const emails = emailList.value
187+
.split('\n')
188+
.map((email) => email.trim())
189+
.filter((email) => email !== '');
190+
191+
// Validate emails
192+
const invalidEmails = emails.filter((email) => !validateEmail(email));
193+
const validEmails = emails.filter((email) => validateEmail(email));
194+
195+
if (invalidEmails.length > 0) {
196+
// Remove invalid emails from emailList
197+
emailList.value = validEmails.join('\n');
198+
// Update invalidEmailsList
199+
invalidEmailsList.value = invalidEmails;
200+
if (validEmails.length === 0) {
201+
$toasted.error(t('All emails were invalid and have been removed.'));
202+
} else {
203+
$toasted.error(
204+
t('Invalid email addresses have been removed: ') +
205+
invalidEmails.join(', ') +
206+
'. ' +
207+
t('Please click Send Emails again.'),
208+
);
209+
}
210+
return;
211+
}
105212
106-
if (invalidEmails.length > 0) {
107-
// Remove invalid emails from emailList
108-
emailList.value = validEmails.join('\n');
109-
// Update invalidEmailsList
110-
invalidEmailsList.value = invalidEmails;
111213
if (validEmails.length === 0) {
112-
$toasted.error(t('All emails were invalid and have been removed.'));
113-
} else {
114-
$toasted.error(
115-
t('Invalid email addresses have been removed: ') +
116-
invalidEmails.join(', ') +
117-
'. ' +
118-
t('Please click Send Emails again.'),
119-
);
214+
$toasted.error(t('No valid email addresses to send.'));
215+
return;
120216
}
121-
return;
122-
}
123217
124-
if (validEmails.length === 0) {
125-
$toasted.error(t('No valid email addresses to send.'));
218+
// Append emails to formData
219+
formData.append('emails', JSON.stringify(validEmails));
220+
} else if (inputMethod.value === 'csvFile') {
221+
if (!csvFile.value) {
222+
$toasted.error(t('Please upload a CSV file.'));
223+
return;
224+
}
225+
formData.append('csv_file', csvFile.value);
226+
} else {
227+
$toasted.error(t('Invalid input method.'));
126228
return;
127229
}
128230
129231
try {
130-
const response = await axios.post(`admins/send_bulk_email`, {
131-
emails: validEmails,
132-
subject: subject.value,
133-
html_message: htmlMessage.value,
232+
const response = await axios.post(`admins/send_bulk_email`, formData, {
233+
headers: {
234+
'Content-Type': 'multipart/form-data',
235+
},
134236
});
135237
136238
if (response.status === 202) {
137239
$toasted.success(t('Emails are being sent.'));
138240
const taskId = response.data.task_id;
139241
checkTaskStatus(taskId);
140242
invalidEmailsList.value = [];
243+
csvErrors.value = [];
141244
}
142245
} catch (error) {
143246
$toasted.error(getErrorMessage(error));
144247
}
145248
};
146249
147-
const validateEmail = (email: string) => {
148-
const emailRegex =
149-
/^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@(([^\s"(),.:;<>@[\\\]]+\.)+[^\s"(),.:;<>@[\\\]]{2,})$/i;
150-
return emailRegex.test(email);
151-
};
152-
153250
const checkTaskStatus = (taskId: string) => {
154251
if (statusInterval) {
155252
clearInterval(statusInterval);
@@ -160,6 +257,12 @@ const checkTaskStatus = (taskId: string) => {
160257
taskStatus.value = statusResponse.data;
161258
if (['SUCCESS', 'FAILURE'].includes(taskStatus.value.state)) {
162259
clearInterval(statusInterval);
260+
261+
// Extract sent and unsuccessful emails from the task result
262+
if (taskStatus.value.result) {
263+
sentEmails.value = taskStatus.value.result.sent || [];
264+
unsuccessfulEmails.value = taskStatus.value.result.unsuccessful || [];
265+
}
163266
}
164267
} catch {
165268
$toasted.error(t('Failed to check task status.'));
@@ -180,11 +283,19 @@ const showPreview = async () => {
180283
},
181284
});
182285
};
286+
287+
const showAdminEmailAssets = async () => {
288+
await component({
289+
title: t('Email Images'),
290+
component: AdminEmailAssets,
291+
classes: 'w-full h-96 overflow-auto p-3',
292+
modalClasses: 'bg-white max-w-3xl shadow',
293+
});
294+
};
183295
</script>
184296

185297
<style scoped>
186-
.wysiwyg-editor {
187-
min-height: 150px;
188-
outline: none;
298+
.send-bulk-email label {
299+
font-weight: normal;
189300
}
190301
</style>

0 commit comments

Comments
 (0)