feat: Inbox list API integration (#8825)

* feat: Inbox view

* feat: Bind real values

* chore: code cleanup

* feat: add observer

* fix: Inbox icon

* chore: more code cleanup

* chore: Replace conversation id

* chore: Minor fix

* chore: Hide from side bar

* chore: Fix eslint

* chore: Minor fix

* fix: dark mode color

* chore: Minor fix

* feat: Add description for each notification types

* chore: remove commented code

* Update InboxList.vue

* Update InboxView.vue

* chore: fix specs

* fix: specs

* Update InboxView.vue

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2024-02-01 12:10:58 +05:30
committed by GitHub
parent b9c62b3fed
commit b7a7e5a0d3
27 changed files with 541 additions and 210 deletions

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 flex-basis-custom overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50" class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{ :class="{
hide: !showConversationList, hide: !showConversationList,
'list--full-width': isOnExpandedLayout, 'list--full-width': isOnExpandedLayout,

View File

@@ -90,9 +90,6 @@
"conversation_mention": "Mention" "conversation_mention": "Mention"
} }
}, },
"INBOX_PAGE": {
"HEADER": "Inbox"
},
"NETWORK": { "NETWORK": {
"NOTIFICATION": { "NOTIFICATION": {
"OFFLINE": "Offline" "OFFLINE": "Offline"

View File

@@ -0,0 +1,17 @@
{
"INBOX": {
"LIST": {
"TITLE": "Inbox",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group."
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
"CONVERSATION_CREATION": "New conversation created",
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
}
}
}

View File

@@ -29,6 +29,7 @@ import settings from './settings.json';
import signup from './signup.json'; import signup from './signup.json';
import teamsSettings from './teamsSettings.json'; import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json'; import whatsappTemplates from './whatsappTemplates.json';
import inbox from './inbox.json';
export default { export default {
...advancedFilters, ...advancedFilters,
@@ -62,4 +63,5 @@ export default {
...signup, ...signup,
...teamsSettings, ...teamsSettings,
...whatsappTemplates, ...whatsappTemplates,
...inbox,
}; };

View File

@@ -193,6 +193,7 @@
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:", "CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"SWITCH": "Switch", "SWITCH": "Switch",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"INBOX": "Inbox",
"ALL_CONVERSATIONS": "All Conversations", "ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions", "MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating", "PARTICIPATING_CONVERSATIONS": "Participating",

View File

@@ -52,19 +52,77 @@ describe('#dateFormat', () => {
}); });
describe('#shortTimestamp', () => { describe('#shortTimestamp', () => {
it('returns correct value', () => { // Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual( expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual(
'now' 'now'
); );
expect(TimeMixin.methods.shortTimestamp(' minute ago')).toEqual('m'); expect(TimeMixin.methods.shortTimestamp('1 minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp(' minutes ago')).toEqual('m'); expect(TimeMixin.methods.shortTimestamp('12 minutes ago')).toEqual('12m');
expect(TimeMixin.methods.shortTimestamp(' hour ago')).toEqual('h'); expect(TimeMixin.methods.shortTimestamp('a minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp(' hours ago')).toEqual('h'); expect(TimeMixin.methods.shortTimestamp('an hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp(' day ago')).toEqual('d'); expect(TimeMixin.methods.shortTimestamp('1 hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp(' days ago')).toEqual('d'); expect(TimeMixin.methods.shortTimestamp('2 hours ago')).toEqual('2h');
expect(TimeMixin.methods.shortTimestamp(' month ago')).toEqual('mo'); expect(TimeMixin.methods.shortTimestamp('1 day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp(' months ago')).toEqual('mo'); expect(TimeMixin.methods.shortTimestamp('a day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp(' year ago')).toEqual('y'); expect(TimeMixin.methods.shortTimestamp('3 days ago')).toEqual('3d');
expect(TimeMixin.methods.shortTimestamp(' years ago')).toEqual('y'); expect(TimeMixin.methods.shortTimestamp('a month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('1 month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('2 months ago')).toEqual('2mo');
expect(TimeMixin.methods.shortTimestamp('a year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('1 year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('4 years ago')).toEqual('4y');
});
// Test cases when withAgo is true
it('returns correct value with ago', () => {
expect(
TimeMixin.methods.shortTimestamp('less than a minute ago', true)
).toEqual('now');
expect(TimeMixin.methods.shortTimestamp('1 minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('12 minutes ago', true)).toEqual(
'12m ago'
);
expect(TimeMixin.methods.shortTimestamp('a minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('an hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('2 hours ago', true)).toEqual(
'2h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('a day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('3 days ago', true)).toEqual(
'3d ago'
);
expect(TimeMixin.methods.shortTimestamp('a month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('1 month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('2 months ago', true)).toEqual(
'2mo ago'
);
expect(TimeMixin.methods.shortTimestamp('a year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('1 year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('4 years ago', true)).toEqual(
'4y ago'
);
}); });
}); });

View File

@@ -28,25 +28,36 @@ export default {
const unixTime = fromUnixTime(time); const unixTime = fromUnixTime(time);
return format(unixTime, dateFormat); return format(unixTime, dateFormat);
}, },
shortTimestamp(time) { shortTimestamp(time, withAgo = false) {
// This function takes a time string and converts it to a short time string
// with the following format: 1m, 1h, 1d, 1mo, 1y
// The function also takes an optional boolean parameter withAgo
// which will add the word "ago" to the end of the time string
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
'a month ago': `1mo${suffix}`,
'a year ago': `1y${suffix}`,
};
// Check if the time string is one of the specific cases
if (timeMappings[time]) {
return timeMappings[time];
}
const convertToShortTime = time const convertToShortTime = time
.replace(/about|over|almost|/g, '') .replace(/about|over|almost|/g, '')
.replace('less than a minute ago', 'now') .replace(' minute ago', `m${suffix}`)
.replace(' minute ago', 'm') .replace(' minutes ago', `m${suffix}`)
.replace(' minutes ago', 'm') .replace(' hour ago', `h${suffix}`)
.replace('a minute ago', 'm') .replace(' hours ago', `h${suffix}`)
.replace('an hour ago', 'h') .replace(' day ago', `d${suffix}`)
.replace(' hour ago', 'h') .replace(' days ago', `d${suffix}`)
.replace(' hours ago', 'h') .replace(' month ago', `mo${suffix}`)
.replace(' day ago', 'd') .replace(' months ago', `mo${suffix}`)
.replace('a day ago', 'd') .replace(' year ago', `y${suffix}`)
.replace(' days ago', 'd') .replace(' years ago', `y${suffix}`);
.replace('a month ago', 'mo')
.replace(' months ago', 'mo')
.replace(' month ago', 'mo')
.replace('a year ago', 'y')
.replace(' year ago', 'y')
.replace(' years ago', 'y');
return convertToShortTime; return convertToShortTime;
}, },
}, },

View File

@@ -1,9 +1,17 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView'); const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default { export default {
routes: [ routes: [
{
path: frontendURL('accounts/:accountId/inbox'),
name: 'inbox',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {},
},
{ {
path: frontendURL('accounts/:accountId/dashboard'), path: frontendURL('accounts/:accountId/dashboard'),
name: 'home', name: 'home',

View File

@@ -5,7 +5,6 @@ import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes'; import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
import { routes as inboxRoutes } from './inbox/routes';
const AppContainer = () => import('./Dashboard.vue'); const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue'); const Suspended = () => import('./suspended/Index.vue');
@@ -22,7 +21,6 @@ export default {
...contactRoutes, ...contactRoutes,
...searchRoutes, ...searchRoutes,
...notificationRoutes, ...notificationRoutes,
...inboxRoutes,
], ],
}, },
{ {

View File

@@ -1,70 +0,0 @@
<script setup>
// import { defineProps } from 'vue';
import PriorityIcon from './components/PriorityIcon.vue';
import StatusIcon from './components/StatusIcon.vue';
import InboxNameAndId from './components/InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
// const props = defineProps({
// notificationItem: {
// type: Object,
// default: () => {},
// },
// });
const assigneeMeta = {
thumbnail: '',
name: 'Michael Johnson',
};
const { thumbnail, name } = assigneeMeta || {};
const status = 'open';
const priority = 'high';
const inboxTypeMessage = 'Mentioned by Michael';
const inboxMessage = 'What is the best way to get started?';
const inbox = {
inbox_id: 16787,
inbox_name: 'Chatwoot Support',
};
</script>
<template>
<div
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
>
<div class="flex relative items-center justify-between w-full">
<div
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" />
<div class="flex gap-2">
<PriorityIcon :priority="priority" />
<StatusIcon :status="status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[80%]">
<Thumbnail
v-if="assigneeMeta"
:src="thumbnail"
:username="name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
<span class="font-normal">{{ inboxMessage }}</span>
</span>
</div>
</div>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
10h ago
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script>
import { mapGetters } from 'vuex';
import InboxCard from './components/InboxCard.vue';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
export default {
components: {
InboxCard,
IntersectionObserver,
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
},
mounted() {
this.$store.dispatch('notifications/clear');
this.$store.dispatch('notifications/index', { page: 1 });
},
methods: {
openConversation(notification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
notification_type: notificationType,
} = notification;
this.$track(ACCOUNT_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
notificationType,
});
this.$store.dispatch('notifications/read', {
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push(
`/app/accounts/${this.accountId}/conversations/${conversationId}`
);
},
onMarkAllDoneClick() {
this.$track(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS);
this.$store.dispatch('notifications/readAll');
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', { page: this.page + 1 });
this.page += 1;
},
},
};
</script>
<template>
<div
class="flex flex-col min-w-[360px] w-full max-w-[360px] h-full ltr:border-r border-slate-50 dark:border-slate-800/50"
>
<div
class="flex text-xl w-full pl-5 pr-3 py-2 h-14 items-center font-medium text-slate-900 dark:text-slate-25 border-b border-slate-50 dark:border-slate-800/50"
>
{{ $t('INBOX.LIST.TITLE') }}
</div>
<div
ref="notificationList"
class="flex flex-col w-full h-full overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
:key="notificationItem.id"
:notification-item="notificationItem"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
}),
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
},
},
mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
// TODO: Remove this code once inbox view feature is enabled for all accounts
if (!this.isInboxViewEnabled) {
this.$router.push({
name: 'home',
});
}
},
};
</script>
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<InboxList />
<div class="flex flex-col w-full h-full">
<InboxItemHeader :total-length="28" :current-index="1" />
<ConversationBox class="h-full" />
</div>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<template>
<div
role="button"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
@click="openConversation(notificationItem)"
>
<div class="flex relative items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[calc(100%-70px)]">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
>
<span class="font-normal text-sm">
{{ pushTitle }}
</span>
</span>
</div>
</div>
<span
class="font-medium max-w-[60px] text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
</div>
</template>
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
PriorityIcon,
StatusIcon,
InboxNameAndId,
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
computed: {
primaryActor() {
return this.notificationItem?.primary_actor;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
);
},
isUnread() {
return !this.notificationItem?.read_at;
},
meta() {
return this.primaryActor?.meta;
},
assigneeMeta() {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
},
lastActivityAt() {
const dynamicTime = this.dynamicTime(
this.notificationItem?.last_activity_at
);
return this.shortTimestamp(dynamicTime, true);
},
},
methods: {
openConversation(notification) {
this.$emit('open-conversation', notification);
},
},
};
</script>

View File

@@ -1,33 +1,34 @@
<script setup> <script>
import PaginationButton from './PaginationButton.vue'; import PaginationButton from './PaginationButton.vue';
const props = defineProps({ export default {
totalLength: { components: {
type: Number, PaginationButton,
default: 0,
}, },
currentIndex: { props: {
type: Number, totalLength: {
default: 0, type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
methods: {
onSnooze() {},
onDelete() {},
}, },
});
const onSnooze = () => {
// TODO: Implement snooze
};
const onDelete = () => {
// TODO: Implement delete
}; };
</script> </script>
<template> <template>
<div <div
class="flex gap-2 py-2 pl-4 pr-2 justify-between items-center w-full border-b border-slate-200 dark:border-slate-500" class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
> >
<pagination-button <pagination-button
:total-length="props.totalLength" :total-length="totalLength"
:current-index="props.currentIndex" :current-index="currentIndex"
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<woot-button <woot-button

View File

@@ -1,33 +1,43 @@
<script setup> <script>
import { defineProps } from 'vue'; import { getInboxClassByType } from 'dashboard/helper/inbox';
const props = defineProps({ export default {
inbox: { props: {
type: Object, inbox: {
default: () => {}, type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
}, },
}); computed: {
inboxIcon() {
const { inbox } = props; const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const { inbox_id: inboxId, inbox_name: inboxName } = inbox; const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script> </script>
<template> <template>
<div <div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none" class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none"
> >
<div class="flex items-center gap-0.5 py-0.5 px-1.5"> <div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon <fluent-icon
class="text-slate-600 dark:text-slate-300" class="text-slate-600 dark:text-slate-300"
icon="globe-desktop" :icon="inboxIcon"
size="14" size="14"
/> />
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs"> <span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inboxName }} {{ inbox.name }}
</span> </span>
</div> </div>
<div class="flex items-center py-0.5 px-1.5"> <div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs"> <span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inboxId }} {{ conversationId }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +0,0 @@
<template>
<h1>Inbox View</h1>
</template>
<script setup></script>

View File

@@ -1,20 +1,25 @@
<script setup> <script>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages'; import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { defineProps } from 'vue'; export default {
props: {
const props = defineProps({ priority: {
priority: { type: String,
type: String, default: '',
default: '', },
}, },
}); data() {
return {
CONVERSATION_PRIORITY,
};
},
};
</script> </script>
<template> <template>
<div class="inline-flex items-center justify-center rounded-md"> <div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority --> <!-- High Priority -->
<svg <svg
v-if="props.priority === CONVERSATION_PRIORITY.HIGH" v-if="priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -29,7 +34,7 @@ const props = defineProps({
<!-- Low Priority --> <!-- Low Priority -->
<svg <svg
v-if="props.priority === CONVERSATION_PRIORITY.LOW" v-if="priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -44,7 +49,7 @@ const props = defineProps({
<!-- Medium Priority --> <!-- Medium Priority -->
<svg <svg
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM" v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"
@@ -59,7 +64,7 @@ const props = defineProps({
<!-- Urgent Priority --> <!-- Urgent Priority -->
<svg <svg
v-if="props.priority === CONVERSATION_PRIORITY.URGENT" v-if="priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4" class="h-4 w-4"
width="24" width="24"
height="24" height="24"

View File

@@ -1,20 +1,8 @@
<script setup>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
status: {
type: String,
default: '',
},
});
</script>
<template> <template>
<div class="inline-flex items-center justify-center rounded-md"> <div class="inline-flex items-center justify-center rounded-md">
<!-- Pending --> <!-- Pending -->
<svg <svg
v-if="props.status === CONVERSATION_STATUS.PENDING" v-if="status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="18" width="18"
height="18" height="18"
@@ -29,7 +17,7 @@ const props = defineProps({
</svg> </svg>
<!-- Open --> <!-- Open -->
<svg <svg
v-if="props.status === CONVERSATION_STATUS.OPEN" v-if="status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="19" width="19"
height="19" height="19"
@@ -45,7 +33,7 @@ const props = defineProps({
<!-- Snoozed --> <!-- Snoozed -->
<svg <svg
v-if="props.status === CONVERSATION_STATUS.SNOOZED" v-if="status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
width="18" width="18"
height="18" height="18"
@@ -61,7 +49,7 @@ const props = defineProps({
<!-- Resolved --> <!-- Resolved -->
<svg <svg
v-if="props.status === CONVERSATION_STATUS.RESOLVED" v-if="status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5" class="h-3.5 w-3.5"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -78,3 +66,21 @@ const props = defineProps({
</svg> </svg>
</div> </div>
</template> </template>
<script>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
props: {
status: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_STATUS,
};
},
};
</script>

View File

@@ -1,25 +0,0 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const SettingsWrapper = () => import('../settings/Wrapper.vue');
const InboxView = () => import('./components/InboxView.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox'),
component: SettingsWrapper,
props: {
headerTitle: 'INBOX_PAGE.HEADER',
icon: 'alert',
showNewButton: false,
showSidemenuIcon: false,
},
children: [
{
path: '',
name: 'inbox_index',
component: InboxView,
roles: ['administrator', 'agent'],
},
],
},
];

View File

@@ -5,7 +5,6 @@ import dashboard from './dashboard/dashboard.routes';
import store from '../store'; import store from '../store';
import { validateLoggedInRoutes } from '../helper/routeHelpers'; import { validateLoggedInRoutes } from '../helper/routeHelpers';
import AnalyticsHelper from '../helper/AnalyticsHelper'; import AnalyticsHelper from '../helper/AnalyticsHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const routes = [...dashboard.routes]; const routes = [...dashboard.routes];
@@ -42,16 +41,6 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
return '/app/login'; return '/app/login';
} }
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
// TODO: Remove this code once inbox view feature is enabled for all accounts
const isInboxViewEnabled = store.getters['accounts/isFeatureEnabledGlobally'](
user.account_id,
FEATURE_FLAGS.INBOX_VIEW
);
if (to.name === 'inbox_index' && !isInboxViewEnabled) {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}
if (!to.name) { if (!to.name) {
return next(frontendURL(`accounts/${user.account_id}/dashboard`)); return next(frontendURL(`accounts/${user.account_id}/dashboard`));
} }

View File

@@ -18,6 +18,24 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
} }
}, },
index: async ({ commit }, { page = 1 } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
if (payload.length < 15) {
commit(types.SET_ALL_NOTIFICATIONS_LOADED);
}
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => { unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true }); commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try { try {
@@ -59,4 +77,7 @@ export const actions = {
deleteNotification({ commit }, data) { deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data); commit(types.DELETE_NOTIFICATION, data);
}, },
clear({ commit }) {
commit(types.CLEAR_NOTIFICATIONS);
},
}; };

View File

@@ -14,6 +14,7 @@ const state = {
isFetchingItem: false, isFetchingItem: false,
isUpdating: false, isUpdating: false,
isUpdatingUnreadCount: false, isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false,
}, },
}; };

View File

@@ -10,6 +10,7 @@ export const mutations = {
}, },
[types.CLEAR_NOTIFICATIONS]: $state => { [types.CLEAR_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {}); Vue.set($state, 'records', {});
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', false);
}, },
[types.SET_NOTIFICATIONS_META]: ($state, data) => { [types.SET_NOTIFICATIONS_META]: ($state, data) => {
const { const {
@@ -61,4 +62,7 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount); Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count); Vue.set($state.meta, 'count', count);
}, },
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
},
}; };

View File

@@ -39,6 +39,38 @@ describe('#actions', () => {
}); });
}); });
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
data: {
payload: [{ id: 1 }],
meta: { count: 3, current_page: 1, unread_count: 2 },
},
},
});
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS, [{ id: 1 }]],
[
types.SET_NOTIFICATIONS_META,
{ count: 3, current_page: 1, unread_count: 2 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
[types.SET_ALL_NOTIFICATIONS_LOADED],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#unReadCount', () => { describe('#unReadCount', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: 1 }); axios.get.mockResolvedValue({ data: 1 });
@@ -107,4 +139,11 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('clear', () => {
it('sends correct actions', async () => {
await actions.clear({ commit });
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
});
});
}); });

View File

@@ -12,7 +12,10 @@ describe('#mutations', () => {
describe('#CLEAR_NOTIFICATIONS', () => { describe('#CLEAR_NOTIFICATIONS', () => {
it('clear notifications', () => { it('clear notifications', () => {
const state = { records: { 1: { id: 1 } } }; const state = {
records: { 1: { id: 1 } },
uiFlags: { isAllNotificationsLoaded: true },
};
mutations[types.CLEAR_NOTIFICATIONS](state); mutations[types.CLEAR_NOTIFICATIONS](state);
expect(state.records).toEqual({}); expect(state.records).toEqual({});
}); });
@@ -141,4 +144,12 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232); expect(state.meta.count).toEqual(232);
}); });
}); });
describe('#SET_ALL_NOTIFICATIONS_LOADED', () => {
it('set all notifications loaded', () => {
const state = { uiFlags: { isAllNotificationsLoaded: false } };
mutations[types.SET_ALL_NOTIFICATIONS_LOADED](state);
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
});
});
}); });

View File

@@ -140,6 +140,7 @@ export default {
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS', CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS', EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE', UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
// Contact Conversation // Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',

View File

@@ -125,6 +125,7 @@
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z", "location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z", "lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z", "lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z", "mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z", "mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z", "mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",