Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/router/index.js
Original file line number Diff line number Diff line change
@@ -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: '/' },
],
});
Expand Down
6 changes: 5 additions & 1 deletion assets/vue/api.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
154 changes: 154 additions & 0 deletions assets/vue/components/lists/AddSubscribersModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0"
aria-labelledby="add-subscribers-modal-title"
role="dialog"
aria-modal="true"
>
<div class="fixed inset-0 bg-slate-900/50 transition-opacity" aria-hidden="true" @click="close"></div>

<div class="relative z-10 w-full overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:max-w-lg sm:w-full">
<form class="mt-4 space-y-4" @submit.prevent="submitAddSubscribers">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6">
<div class="flex items-center justify-between">
<h3 id="add-subscribers-modal-title" class="text-lg font-medium leading-6 text-slate-900">
Add subscribers
</h3>

<button type="button" class="text-slate-400 hover:text-slate-500" @click="close">
<BaseIcon name="close" class="w-5 h-5" />
</button>
</div>

<div>
<label for="subscriber-emails" class="block text-sm font-medium text-slate-700">
Email addresses
</label>
<textarea
id="subscriber-emails"
v-model.trim="addSubsForm.emails"
rows="8"
placeholder="john@example.com&#10;jane@example.com&#10;team@example.com"
class="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm"
></textarea>
<p class="mt-1 text-xs text-slate-500">
Enter one email per line, or separate multiple emails with commas.
</p>
</div>

<p v-if="addSubsError" class="text-sm text-red-600">
{{ addSubsError }}
</p>
</div>

<div class="bg-slate-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<button
type="submit"
:disabled="addingSubscribers || !addSubsForm.emails.trim()"
class="w-full inline-flex justify-center rounded-md border border-transparent bg-ext-wf1 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-ext-wf3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:w-auto sm:text-sm disabled:opacity-50"
>
{{ addingSubscribers ? 'Adding...' : 'Add subscribers' }}
</button>

<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
@click="close"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</template>

<script setup>
import { ref, watch } from 'vue'
import BaseIcon from '../base/BaseIcon.vue'
import { subscriptionClient } from '../../api'

const props = defineProps({
isOpen: Boolean,
list: {
type: Object,
default: null
}
})

const emit = defineEmits(['close', 'added'])

const addingSubscribers = ref(false)
const addSubsError = ref('')
const addSubsForm = ref({
emails: ''
})

const resetAddSubsForm = () => {
addSubsForm.value = {
emails: ''
}
addSubsError.value = ''
}

watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
resetAddSubsForm()
}
}
)

const close = () => {
if (addingSubscribers.value) return
emit('close')
}

const parseEmails = (raw) => {
return raw
.split(/[\n,;]/)
.map(email => email.trim())
.filter(Boolean)
}

const isValidEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

const submitAddSubscribers = async () => {
if (addingSubscribers.value) return

if (!props.list?.id) {
addSubsError.value = 'No mailing list selected.'
return
}

const emails = parseEmails(addSubsForm.value.emails)

if (!emails.length) {
addSubsError.value = 'At least one email is required.'
return
}

const invalidEmails = emails.filter(email => !isValidEmail(email))
if (invalidEmails.length) {
addSubsError.value = `Invalid email(s): ${invalidEmails.join(', ')}`
return
}

addingSubscribers.value = true
addSubsError.value = ''

try {
await subscriptionClient.createSubscriptions(emails, props.list.id)
emit('added')
emit('close')
} catch (error) {
addSubsError.value = error?.message || 'Failed to add subscribers to the list.'
} finally {
addingSubscribers.value = false
}
}
</script>
177 changes: 177 additions & 0 deletions assets/vue/components/lists/CreateListModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0"
aria-labelledby="create-list-modal-title"
role="dialog"
aria-modal="true"
>
<div class="fixed inset-0 bg-slate-900/50 transition-opacity" aria-hidden="true" @click="close"></div>
<form class="mt-4 space-y-4" @submit.prevent="submitCreateList">
<div class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg md:min-w-xl sm:w-full z-10">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6">
<div class="flex justify-between items-center">
<h3 id="create-list-modal-title" class="text-lg leading-6 font-medium text-slate-900">
Create New List
</h3>
<button type="button" class="text-slate-400 hover:text-slate-500" @click="close">
<BaseIcon name="close" class="w-5 h-5" />
</button>
Comment on lines +17 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an accessible name to the icon-only close button.

Like the edit modal, this dismiss button should expose an explicit label for assistive tech.

💡 Suggested fix
-            <button type="button" class="text-slate-400 hover:text-slate-500" `@click`="close">
+            <button
+              type="button"
+              class="text-slate-400 hover:text-slate-500"
+              aria-label="Close create list modal"
+              `@click`="close"
+            >
               <BaseIcon name="close" class="w-5 h-5" />
             </button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/vue/components/lists/CreateListModal.vue` around lines 17 - 19, The
close button in CreateListModal.vue is icon-only and lacks an accessible name;
update the button that calls the close method (the <button ... `@click`="close">
wrapping BaseIcon) to provide an explicit label for assistive tech by adding
either an aria-label="Close" (or aria-label bound to a localized string) or
include a visually hidden text node (e.g., a span with the app’s "sr-only" class
containing "Close") alongside the BaseIcon so screen readers can announce the
button.

</div>

<div>
<label for="list-name" class="block text-sm font-medium text-slate-700">Name</label>
<input
id="list-name"
v-model.trim="createForm.name"
type="text"
required
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"
>
</div>

<div class="flex items-center">
<input
id="list-public"
v-model="createForm.public"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded"
>
<label for="list-public" class="ml-2 block text-sm text-slate-900">
Public
</label>
</div>

<div>
<label for="list-position" class="block text-sm font-medium text-slate-700">List Position (optional)</label>
<input
id="list-position"
v-model="createForm.listPosition"
type="number"
min="0"
step="1"
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"
>
</div>

<div>
<label for="list-description" class="block text-sm font-medium text-slate-700">Description (optional)</label>
<textarea
id="list-description"
v-model.trim="createForm.description"
rows="3"
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"
></textarea>
</div>

<p v-if="createError" class="text-sm text-red-600">{{ createError }}</p>
</div>

<div class="bg-slate-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2">
<button
type="submit"
:disabled="creatingList || !createForm.name.trim()"
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"
>
{{ creatingList ? 'Creating...' : 'Create' }}
</button>
<button
type="button"
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"
@click="close"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { Requests } from '@tatevikgr/rest-api-client'
import BaseIcon from '../base/BaseIcon.vue'
import { listClient } from '../../api'

const props = defineProps({
isOpen: Boolean
})

const emit = defineEmits(['close', 'created'])

const creatingList = ref(false)
const createError = ref('')
const createForm = ref({
name: '',
public: false,
listPosition: '',
description: ''
})

const resetCreateForm = () => {
createForm.value = {
name: '',
public: false,
listPosition: '',
description: ''
}
createError.value = ''
}

watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
resetCreateForm()
}
}
)

const close = () => {
if (creatingList.value) {
return
}

emit('close')
}

const submitCreateList = async () => {
if (creatingList.value) {
return
}

const name = createForm.value.name.trim()
if (!name) {
createError.value = 'Name is required.'
return
}

const parsedPosition = createForm.value.listPosition === '' ? null : Number(createForm.value.listPosition)

Comment on lines +150 to +151
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Trim listPosition before numeric coercion.

Number(...) without trimming can turn whitespace-like values into 0. Align this with Edit modal parsing to avoid accidental acceptance.

💡 Suggested fix
-  const parsedPosition = createForm.value.listPosition === '' ? null : Number(createForm.value.listPosition)
+  const rawPosition = String(createForm.value.listPosition).trim()
+  const parsedPosition = rawPosition === '' ? null : Number(rawPosition)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsedPosition = createForm.value.listPosition === '' ? null : Number(createForm.value.listPosition)
const rawPosition = String(createForm.value.listPosition).trim()
const parsedPosition = rawPosition === '' ? null : Number(rawPosition)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/vue/components/lists/CreateListModal.vue` around lines 150 - 151, The
parsing of listPosition in CreateListModal.vue should trim whitespace before
deciding emptiness and coercion: replace the existing parsedPosition expression
that uses createForm.value.listPosition directly with a trimmed check and
trimmed Number conversion (e.g. use const raw =
createForm.value.listPosition?.trim() ?? ''; const parsedPosition = raw === '' ?
null : Number(raw)) so whitespace-only input doesn't become 0; update references
to parsedPosition accordingly.

if (parsedPosition !== null && (!Number.isInteger(parsedPosition) || parsedPosition < 0)) {
createError.value = 'List Position must be a whole number greater than or equal to 0.'
return
}

creatingList.value = true
createError.value = ''

try {
const request = new Requests.CreateSubscriberListRequest(
name,
!!createForm.value.public,
parsedPosition,
createForm.value.description.trim() || null
)

const createdList = await listClient.createList(request)
emit('created', createdList)
emit('close')
} catch (error) {
createError.value = error?.message || 'Failed to create list.'
} finally {
creatingList.value = false
}
}
</script>
Loading
Loading