Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Copy Markdown

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
Copy Markdown

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