Compare commits

..
Author SHA1 Message Date
Pranav 9e6346326d Merge branch 'develop' into design-updates 2024-12-11 19:53:08 -08:00
a11746106f feat: Update design for report pages (#10506)
<img width="1440" alt="Screenshot 2024-11-26 at 8 38 57 PM"
src="https://github.com/user-attachments/assets/f752157c-6134-42cb-8211-ce636ea9e4d6">
<img width="1439" alt="Screenshot 2024-11-26 at 8 40 47 PM"
src="https://github.com/user-attachments/assets/580b1f61-68bc-489b-9081-b0aeb402f31d">

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-12-05 10:01:07 -08:00
Pranav 1f75bed30f Merge branch 'develop' into design-updates 2024-12-05 09:57:22 -08:00
Pranav 2989fab4d1 Merge branch 'develop' into design-updates 2024-11-20 20:59:22 -08:00
Pranav bf3eace7a2 Update billing 2024-11-20 18:38:07 -08:00
Pranav 226d0db11c Update design 2024-11-20 18:31:24 -08:00
Shivam MishraandPranav 82c73f18d9 Update design 2024-11-20 17:13:18 -08:00
Pranav 16432a6ebc Design Updates 2024-11-20 15:56:14 -08:00
153 changed files with 942 additions and 9535 deletions
+3 -7
View File
@@ -12,7 +12,6 @@ on:
- master
tags:
- v*
# pull_request:
workflow_dispatch:
jobs:
@@ -41,9 +40,7 @@ jobs:
- name: set docker tag
run: |
# Replace forward slashes with hyphens in the ref name
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
@@ -51,7 +48,6 @@ jobs:
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -62,6 +58,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64, linux/arm64
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}
@@ -135,7 +135,6 @@
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
@@ -143,10 +142,10 @@
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
--white-alpha: 255, 255, 255, 0.1;
}
.dark {
body.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
@@ -222,7 +221,6 @@
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
@@ -232,7 +230,7 @@
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.8;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
@@ -18,7 +18,7 @@
}
&.multiselect--disabled {
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
.multiselect__select {
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
@@ -44,7 +44,7 @@
}
.multiselect__content-wrapper {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
@apply bg-white dark:bg-slate-900 border border-solid border-n-weak text-slate-800 dark:text-slate-100;
}
.multiselect__content {
@@ -96,7 +96,7 @@
}
.multiselect__tags {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
@apply bg-n-slate-1 border border-solid border-n-weak m-0 min-h-[2.875rem] pt-0;
input {
@apply border-0 border-none;
@@ -121,7 +121,7 @@
.multiselect__input {
@include ghost-input;
@apply text-sm h-[2.875rem] mb-0 p-0;
@apply text-sm !h-[2.375rem] mb-0 !py-0 ;
}
.multiselect__single {
@@ -179,7 +179,7 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -74,7 +74,10 @@ input[type='password']:not(.reset-base),
input[type='date']:not(.reset-base),
input[type='email']:not(.reset-base),
input[type='url']:not(.reset-base) {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply block box-border w-full transition-colors;
@apply focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out];
@apply h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md font-normal text-sm;
@apply bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
&[disabled] {
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
@@ -90,7 +93,9 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-position: right -1rem center;
background-size: 9px 6px;
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2;
@apply rounded-md w-full text-base font-normal appearance-none transition-colors;
@apply text-sm duration-[0.25s] ease-[ease-in-out] bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
}
// Textarea
@@ -3,15 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-slate-50 dark:border-slate-800/50;
}
.tabs--container--compact.tab--chat-type {
.tabs-title {
a {
@apply py-2 text-sm;
}
}
@apply border-b border-n-weak;
}
.tabs {
@@ -27,22 +19,12 @@
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
}
// Tab chat type
.tab--chat-type {
@apply flex;
.tabs-title {
a {
@apply text-base font-medium py-3;
}
}
}
.tabs-title {
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
@apply bg-n-slate-3 rounded-xl text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@@ -56,22 +38,22 @@
&:hover,
&:focus {
a {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-11;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-10 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
@apply border-b border-n-brand text-n-brand;
}
.badge {
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
@apply bg-n-slate-3 text-n-brand;
}
}
}
@@ -18,10 +18,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
conversationInboxType: {
type: String,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
@@ -51,7 +47,7 @@ watch(
<template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<div ref="chatContainer" class="flex-1 overflow-y-auto py-4 space-y-6 px-4">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
@@ -61,13 +57,12 @@ watch(
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<CopilotInput class="mx-3 mt-px mb-4" @send="sendMessage" />
<CopilotInput class="mx-3 mb-4 mt-px" @send="sendMessage" />
</div>
</template>
@@ -1,36 +1,12 @@
<script setup>
import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
const props = defineProps({
defineProps({
message: {
type: Object,
required: true,
},
conversationInboxType: {
type: String,
required: true,
},
});
const insertIntoRichEditor = computed(() => {
return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes(
props.conversationInboxType
);
});
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
} else {
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content);
}
};
</script>
<template>
@@ -46,15 +22,6 @@ const useCopilotResponse = () => {
<div class="break-words">
{{ message.content }}
</div>
<div class="flex flex-row mt-1">
<Button
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
</div>
</div>
</div>
</template>
@@ -1,36 +0,0 @@
<script setup>
import FileIcon from './FileIcon.vue';
const files = [
{ name: 'file.7z', type: '7z' },
{ name: 'file.zip', type: 'zip' },
{ name: 'file.rar', type: 'rar' },
{ name: 'file.tar', type: 'tar' },
{ name: 'file.csv', type: 'csv' },
{ name: 'file.docx', type: 'docx' },
{ name: 'file.doc', type: 'doc' },
{ name: 'file.odt', type: 'odt' },
{ name: 'file.pdf', type: 'pdf' },
{ name: 'file.ppt', type: 'ppt' },
{ name: 'file.pptx', type: 'pptx' },
{ name: 'file.rtf', type: 'rtf' },
{ name: 'file.json', type: 'json' },
{ name: 'file.txt', type: 'txt' },
{ name: 'file.xls', type: 'xls' },
{ name: 'file.xlsx', type: 'xlsx' },
];
</script>
<template>
<Story title="Components/Icons/FileIcon">
<div class="grid grid-cols-4 gap-5">
<div
v-for="file in files"
:key="file.type"
class="flex items-center gap-2"
>
<FileIcon :file-type="file.type" class="size-6" />
<p>{{ file.name }}</p>
</div>
</div>
</Story>
</template>
@@ -1,38 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
const { fileType } = defineProps({
fileType: {
type: String,
required: true,
},
});
const fileTypeIcon = computed(() => {
const fileIconMap = {
'7z': 'i-woot-file-zip',
csv: 'i-woot-file-csv',
doc: 'i-woot-file-doc',
docx: 'i-woot-file-doc',
json: 'i-woot-file-txt',
odt: 'i-woot-file-doc',
pdf: 'i-woot-file-pdf',
ppt: 'i-woot-file-ppt',
pptx: 'i-woot-file-ppt',
rar: 'i-woot-file-zip',
rtf: 'i-woot-file-doc',
tar: 'i-woot-file-zip',
txt: 'i-woot-file-txt',
xls: 'i-woot-file-xls',
xlsx: 'i-woot-file-xls',
zip: 'i-woot-file-zip',
};
return fileIconMap[fileType] || 'i-teenyicons-text-document-solid';
});
</script>
<template>
<Icon :icon="fileTypeIcon" />
</template>
@@ -1,451 +0,0 @@
<script setup>
import { computed, ref, defineAsyncComponent } from 'vue';
import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt';
import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
MESSAGE_TYPES,
ATTACHMENT_TYPES,
MESSAGE_VARIANTS,
SENDER_TYPES,
ORIENTATION,
MESSAGE_STATUS,
CONTENT_TYPES,
} from './constants';
import Avatar from 'next/avatar/Avatar.vue';
import TextBubble from './bubbles/Text/Index.vue';
import ActivityBubble from './bubbles/Activity.vue';
import ImageBubble from './bubbles/Image.vue';
import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import AttachmentsBubble from './bubbles/Attachments.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
import ContactBubble from './bubbles/Contact.vue';
import DyteBubble from './bubbles/Dyte.vue';
const LocationBubble = defineAsyncComponent(
() => import('./bubbles/Location.vue')
);
import MessageError from './MessageError.vue';
import MessageMeta from './MessageMeta.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Sender
* @property {Object} additional_attributes - Additional attributes of the sender
* @property {Object} custom_attributes - Custom attributes of the sender
* @property {string} email - Email of the sender
* @property {number} id - ID of the sender
* @property {string|null} identifier - Identifier of the sender
* @property {string} name - Name of the sender
* @property {string|null} phone_number - Phone number of the sender
* @property {string} thumbnail - Thumbnail URL of the sender
* @property {string} type - Type of sender
*/
/**
* @typedef {Object} ContentAttributes
* @property {string} externalError - an error message to be shown if the message failed to send
*/
/**
* @typedef {Object} Props
* @property {('sent'|'delivered'|'read'|'failed'|'progress')} status - The delivery status of the message
* @property {ContentAttributes} [contentAttributes={}] - Additional attributes of the message content
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
* @property {Sender|null} [sender=null] - The sender information
* @property {boolean} [private=false] - Whether the message is private
* @property {number|null} [senderId=null] - The ID of the sender
* @property {number} createdAt - Timestamp when the message was created
* @property {number} currentUserId - The ID of the current user
* @property {number} id - The unique identifier for the message
* @property {number} messageType - The type of message (must be one of MESSAGE_TYPES)
* @property {string|null} [error=null] - Error message if the message failed to send
* @property {string|null} [senderType=null] - The type of the sender
* @property {string} content - The message content
* @property {boolean} [groupWithNext=false] - Whether the message should be grouped with the next message
* @property {Object|null} [inReplyTo=null] - The message to which this message is a reply
* @property {boolean} [isEmailInbox=false] - Whether the message is from an email inbox
* @property {number} conversationId - The ID of the conversation to which the message belongs
* @property {number} inboxId - The ID of the inbox to which the message belongs
*/
// eslint-disable-next-line vue/define-macros-order
const props = defineProps({
id: { type: Number, required: true },
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
type: String,
default: 'text',
validator: value => Object.values(CONTENT_TYPES).includes(value),
},
conversationId: { type: Number, required: true },
createdAt: { type: Number, required: true },
currentUserId: { type: Number, required: true },
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, required: true },
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inReplyTo: { type: Object, default: null },
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
sourceId: { type: String, default: '' },
});
const contextMenuPosition = ref({});
const showContextMenu = ref(false);
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
*/
const variant = computed(() => {
if (props.private) return MESSAGE_VARIANTS.PRIVATE;
if (props.isEmailInbox) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
if (emailInboxTypes.includes(props.messageType)) {
return MESSAGE_VARIANTS.EMAIL;
}
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return MESSAGE_VARIANTS.EMAIL;
}
if (props.status === MESSAGE_STATUS.FAILED) return MESSAGE_VARIANTS.ERROR;
if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED;
const variants = {
[MESSAGE_TYPES.INCOMING]: MESSAGE_VARIANTS.USER,
[MESSAGE_TYPES.ACTIVITY]: MESSAGE_VARIANTS.ACTIVITY,
[MESSAGE_TYPES.OUTGOING]: MESSAGE_VARIANTS.AGENT,
[MESSAGE_TYPES.TEMPLATE]: MESSAGE_VARIANTS.TEMPLATE,
};
return variants[props.messageType] || MESSAGE_VARIANTS.USER;
});
const isMyMessage = computed(() => {
// if an outgoing message is still processing, then it's definitely a
// message sent by the current user
if (
props.status === MESSAGE_STATUS.PROGRESS &&
props.messageType === MESSAGE_TYPES.OUTGOING
) {
return true;
}
const senderId = props.senderId ?? props.sender?.id;
const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) {
return false;
}
return (
senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase() &&
props.currentUserId === senderId
);
});
/**
* Computes the message orientation based on sender type and message type
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
*/
const orientation = computed(() => {
if (isMyMessage.value) {
return ORIENTATION.RIGHT;
}
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
return ORIENTATION.LEFT;
});
const flexOrientationClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'justify-start',
[ORIENTATION.RIGHT]: 'justify-end',
[ORIENTATION.CENTER]: 'justify-center',
};
return map[orientation.value];
});
const gridClass = computed(() => {
const map = {
[ORIENTATION.LEFT]: 'grid grid-cols-[24px_1fr]',
[ORIENTATION.RIGHT]: 'grid grid-cols-1fr',
};
return map[orientation.value];
});
const gridTemplate = computed(() => {
const map = {
[ORIENTATION.LEFT]: `
"avatar bubble"
"spacer meta"
`,
[ORIENTATION.RIGHT]: `
"bubble"
"meta"
`,
};
return map[orientation.value];
});
const shouldGroupWithNext = computed(() => {
if (props.status === MESSAGE_STATUS.FAILED) return false;
return props.groupWithNext;
});
const shouldShowAvatar = computed(() => {
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
if (orientation.value === ORIENTATION.RIGHT) return false;
return true;
});
const componentToRender = computed(() => {
if (props.isEmailInbox && !props.private) {
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
if (props.contentAttributes?.isUnsupported) {
return UnsupportedBubble;
}
if (props.contentAttributes.type === 'dyte') {
return DyteBubble;
}
if (props.contentAttributes.imageType === 'story_mention') {
return InstagramStoryBubble;
}
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
if (fileType === ATTACHMENT_TYPES.CONTACT) return ContactBubble;
}
if (
Array.isArray(props.attachments) &&
props.attachments.length > 1 &&
!props.content
) {
return AttachmentsBubble;
}
return TextBubble;
});
const shouldShowContextMenu = computed(() => {
return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
});
const isBubble = computed(() => {
return props.messageType !== MESSAGE_TYPES.ACTIVITY;
});
const isMessageDeleted = computed(() => {
return props.contentAttributes?.deleted;
});
const payloadForContextMenu = computed(() => {
return {
id: props.id,
content_attributes: props.contentAttributes,
content: props.content,
conversation_id: props.conversationId,
};
});
const contextMenuEnabledOptions = computed(() => {
const hasText = !!props.content;
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
return {
copy: hasText,
delete: hasText || hasAttachments,
cannedResponse: isOutgoing && hasText,
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
};
});
function openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
e.preventDefault();
if (e.type === 'contextmenu') {
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
}
contextMenuPosition.value = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
showContextMenu.value = true;
}
function closeContextMenu() {
showContextMenu.value = false;
contextMenuPosition.value = { x: null, y: null };
}
function handleReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
const { conversationId, id: replyTo } = props;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, props);
}
provideMessageContext({
variant,
inReplyTo: props.inReplyTo,
orientation,
isMyMessage,
});
</script>
<template>
<div
:id="`message${props.id}`"
class="flex w-full"
:data-message-id="props.id"
:class="[flexOrientationClass, shouldGroupWithNext ? 'mb-2' : 'mb-4']"
>
<div v-if="variant === MESSAGE_VARIANTS.ACTIVITY">
<ActivityBubble :content="content" />
</div>
<div
v-else
:class="[
gridClass,
{
'gap-y-2': !shouldGroupWithNext,
'w-full': variant === MESSAGE_VARIANTS.EMAIL,
},
]"
class="gap-x-3"
:style="{
gridTemplateAreas: gridTemplate,
}"
>
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
class="[grid-area:avatar] flex items-end"
>
<Avatar
:name="sender ? sender.name : ''"
:src="sender?.thumbnail"
:size="24"
/>
</div>
<div
class="[grid-area:bubble] flex"
:class="{
'pl-9': ORIENTATION.RIGHT === orientation,
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" v-bind="props" />
</div>
<MessageError
v-if="contentAttributes.externalError"
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
/>
<MessageMeta
v-else-if="!shouldGroupWithNext"
class="[grid-area:meta]"
:class="flexOrientationClass"
:sender="props.sender"
:status="props.status"
:private="props.private"
:message-type="props.messageType"
:created-at="props.createdAt"
:source-id="props.sourceId"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu
v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions"
:message="payloadForContextMenu"
hide-button
@open="openContextMenu"
@close="closeContextMenu"
@reply-to="handleReplyTo"
/>
</div>
</div>
</template>
@@ -1,31 +0,0 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
defineProps({
error: { type: String, required: true },
});
const { t } = useI18n();
</script>
<template>
<div class="text-xs text-n-ruby-11 flex items-center gap-1.5">
<span>{{ t('CHAT_LIST.FAILED_TO_SEND') }}</span>
<div class="relative group">
<div
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
>
<Icon
icon="i-lucide-alert-triangle"
class="text-n-ruby-11 size-[14px]"
/>
</div>
<div
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 right-0 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
>
{{ error }}
</div>
</div>
</div>
</template>
@@ -1,146 +0,0 @@
<script setup>
import { defineProps, computed } from 'vue';
import Message from './Message.vue';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
/**
* Props definition for the component
* @typedef {Object} Props
* @property {Array} readMessages - Array of read messages
* @property {Array} unReadMessages - Array of unread messages
* @property {Number} currentUserId - ID of the current user
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
* @property {Array} messages - Array of all messages
*/
const props = defineProps({
readMessages: {
type: Array,
default: () => [],
},
unReadMessages: {
type: Array,
default: () => [],
},
currentUserId: {
type: Number,
required: true,
},
isAnEmailChannel: {
type: Boolean,
default: false,
},
inboxSupportsReplyTo: {
type: Object,
default: () => ({ incoming: false, outgoing: false }),
},
messages: {
type: Array,
default: () => [],
},
shouldShowSpinner: {
type: Boolean,
default: false,
},
unreadMessageCount: {
type: Number,
default: 0,
},
});
const unread = computed(() => {
return useCamelCase(props.unReadMessages, { deep: true });
});
const read = computed(() => {
return useCamelCase(props.readMessages, { deep: true });
});
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
* @param {Array} messages - Array of messages to check
* @returns {Boolean} - Whether the message should be grouped with next
*/
const shouldGroupWithNext = (index, messages) => {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
/**
* Gets the message that was replied to
* @param {Object} parentMessage - The message containing the reply reference
* @returns {Object|null} - The message being replied to, or null if not found
*/
const getInReplyToMessage = parentMessage => {
if (!parentMessage) return null;
const inReplyToMessageId =
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return null;
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
return replyMessage ? useCamelCase(replyMessage) : null;
};
</script>
<template>
<ul class="px-4 bg-n-background">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<Message
v-for="(message, index) in read"
:key="message.id"
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, readMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
{{
unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE')
}}
</span>
</li>
<Message
v-for="(message, index) in unread"
:key="message.id"
v-bind="message"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
<slot name="after" />
</ul>
</template>
@@ -1,173 +0,0 @@
<script setup>
import { computed } from 'vue';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import MessageStatus from './MessageStatus.vue';
import Icon from 'next/icon/Icon.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
/**
* @typedef {Object} Sender
* @property {Object} additional_attributes - Additional attributes of the sender
* @property {Object} custom_attributes - Custom attributes of the sender
* @property {string} email - Email of the sender
* @property {number} id - ID of the sender
* @property {string|null} identifier - Identifier of the sender
* @property {string} name - Name of the sender
* @property {string|null} phone_number - Phone number of the sender
* @property {string} thumbnail - Thumbnail URL of the sender
* @property {string} type - Type of sender
*/
/**
* @typedef {Object} Props
* @property {('sent'|'delivered'|'read'|'failed')} status - The delivery status of the message
* @property {boolean} [private=false] - Whether the message is private
* @property {isMyMessage} [private=false] - Whether the message is sent by the current user or not
* @property {number} createdAt - Timestamp when the message was created
* @property {Sender|null} [sender=null] - The sender information
*/
const props = defineProps({
sender: {
type: Object,
required: true,
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
private: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
sourceId: {
type: String,
default: '',
},
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
});
const {
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
const readableTime = computed(() =>
messageTimestamp(props.createdAt, 'LLL d, h:mm a')
);
const showSender = computed(() => !props.isMyMessage && props.sender);
const showStatusIndicator = computed(() => {
if (props.private) return false;
if (props.messageType === MESSAGE_TYPES.OUTGOING) return true;
if (props.messageType === MESSAGE_TYPES.TEMPLATE) return true;
return false;
});
const isSent = computed(() => {
if (!showStatusIndicator.value) return false;
// Messages will be marked as sent for the Email channel if they have a source ID.
if (isAnEmailChannel.value) return !!props.sourceId;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (props.isALineChannel) return true;
return false;
});
const isDelivered = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return props.status === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
return props.status === MESSAGE_STATUS.DELIVERED;
}
return false;
});
const isRead = computed(() => {
if (!showStatusIndicator.value) return false;
if (
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value
) {
return props.sourceId && props.status === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
return props.status === MESSAGE_STATUS.READ;
}
return false;
});
const statusToShow = computed(() => {
if (isRead.value) return MESSAGE_STATUS.READ;
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
if (isSent.value) return MESSAGE_STATUS.SENT;
return MESSAGE_STATUS.PROGRESS;
});
</script>
<template>
<div class="text-xs text-n-slate-11 flex items-center gap-1.5">
<div class="inline">
<span v-if="showSender" class="inline capitalize">{{ sender.name }}</span>
<span v-if="showSender && readableTime" class="inline"> </span>
<span class="inline">{{ readableTime }}</span>
</div>
<Icon
v-if="props.private"
icon="i-lucide-lock-keyhole"
class="text-n-slate-10 size-3"
/>
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>
</template>
@@ -1,93 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { MESSAGE_STATUS } from './constants';
import Icon from 'next/icon/Icon.vue';
const { status } = defineProps({
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
});
const { t } = useI18n();
const progresIconSequence = [
'i-lucide-clock-1',
'i-lucide-clock-2',
'i-lucide-clock-3',
'i-lucide-clock-4',
'i-lucide-clock-5',
'i-lucide-clock-6',
'i-lucide-clock-7',
'i-lucide-clock-8',
'i-lucide-clock-9',
'i-lucide-clock-10',
'i-lucide-clock-11',
'i-lucide-clock-12',
];
const progessIcon = ref(progresIconSequence[0]);
const rotateIcon = () => {
const currentIndex = progresIconSequence.indexOf(progessIcon.value);
const nextIndex = (currentIndex + 1) % progresIconSequence.length;
progessIcon.value = progresIconSequence[nextIndex];
};
useIntervalFn(rotateIcon, 500, {
immediate: status === MESSAGE_STATUS.PROGRESS,
immediateCallback: false,
});
const statusIcon = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'i-lucide-check',
[MESSAGE_STATUS.DELIVERED]: 'i-lucide-check-check',
[MESSAGE_STATUS.READ]: 'i-lucide-check-check',
};
return statusIconMap[status];
});
const statusColor = computed(() => {
const statusIconMap = {
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
};
return statusIconMap[status];
});
const tooltipText = computed(() => {
const statusTextMap = {
[MESSAGE_STATUS.SENT]: t('CHAT_LIST.SENT'),
[MESSAGE_STATUS.DELIVERED]: t('CHAT_LIST.DELIVERED'),
[MESSAGE_STATUS.READ]: t('CHAT_LIST.MESSAGE_READ'),
[MESSAGE_STATUS.PROGRESS]: t('CHAT_LIST.SENDING'),
};
return statusTextMap[status];
});
</script>
<template>
<Icon
v-if="status === MESSAGE_STATUS.PROGRESS"
v-tooltip.top-start="tooltipText"
:icon="progessIcon"
class="text-n-slate-10"
/>
<Icon
v-else
v-tooltip.top-start="tooltipText"
:icon="statusIcon"
:class="statusColor"
class="size-[14px]"
/>
</template>
@@ -1,16 +0,0 @@
<script setup>
import BaseBubble from './Base.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<BaseBubble class="px-2 py-0.5" data-bubble-name="activity">
<span v-dompurify-html="content" />
</BaseBubble>
</template>
@@ -1,30 +0,0 @@
<script setup>
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
defineProps({
attachments: {
type: Array,
default: () => [],
},
});
</script>
<template>
<BaseBubble class="grid gap-2 bg-transparent" data-bubble-name="attachments">
<AttachmentChips :attachments="attachments" class="gap-1" />
</BaseBubble>
</template>
@@ -1,44 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const attachment = computed(() => {
return props.attachments[0];
});
</script>
<template>
<BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip
:attachment="attachment"
class="p-2 text-n-slate-12 bg-n-alpha-3"
/>
</BaseBubble>
</template>
@@ -1,90 +0,0 @@
<script setup>
import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const { variant, orientation, inReplyTo } = useMessageContext();
const { t } = useI18n();
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
[MESSAGE_VARIANTS.EMAIL]: 'bg-n-alpha-2 w-full',
[MESSAGE_VARIANTS.UNSUPPORTED]:
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
};
const orientationMap = {
[ORIENTATION.LEFT]: 'rounded-xl rounded-bl-sm',
[ORIENTATION.RIGHT]: 'rounded-xl rounded-br-sm',
[ORIENTATION.CENTER]: 'rounded-md',
};
const messageClass = computed(() => {
const classToApply = [varaintBaseMap[variant.value]];
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
classToApply.push(orientationMap[orientation.value]);
} else {
classToApply.push('rounded-lg');
}
return classToApply;
});
const scrollToMessage = () => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
});
};
const previewMessage = computed(() => {
if (!inReplyTo) return '';
const { content, attachments } = inReplyTo;
if (content) return content;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
return t(`CHAT_LIST.ATTACHMENTS.${fileType}.CONTENT`);
}
return t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND');
});
</script>
<template>
<div
class="text-sm min-w-32 break-words"
:class="[
messageClass,
{
'max-w-md': variant !== MESSAGE_VARIANTS.EMAIL,
},
]"
>
<div
v-if="inReplyTo"
class="bg-n-alpha-black1 rounded-lg p-2"
@click="scrollToMessage"
>
<span class="line-clamp-2">
{{ previewMessage }}
</span>
</div>
<slot />
</div>
</template>
@@ -1,78 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
icon: { type: [String, Object], required: true },
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
sender: { type: Object, default: () => ({}) },
senderTranslationKey: { type: String, required: true },
content: { type: String, required: true },
action: {
type: Object,
required: true,
validator: action => {
return action.label && (action.href || action.onClick);
},
},
});
const { t } = useI18n();
const senderName = computed(() => {
return props.sender.name;
});
</script>
<template>
<BaseBubble
class="overflow-hidden grid gap-4 min-w-64 p-0"
data-bubble-name="attachment"
>
<slot name="before" />
<div class="grid gap-3 px-3 pt-3 z-20">
<div
class="size-8 rounded-lg grid place-content-center"
:class="iconBgColor"
>
<slot name="icon">
<Icon :icon="icon" class="text-white size-4" />
</slot>
</div>
<div class="space-y-1">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{
t(senderTranslationKey, {
sender: senderName,
})
}}
</div>
<slot>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
</slot>
</div>
</div>
<div v-if="action" class="px-3 pb-3">
<a
v-if="action.href"
:href="action.href"
rel="noreferrer noopener nofollow"
target="_blank"
class="w-full block bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center"
>
{{ action.label }}
</a>
<button
v-else
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</BaseBubble>
</template>
@@ -1,137 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const $store = useStore();
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
});
const phoneNumber = computed(() => {
return attachment.value.fallbackTitle;
});
const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
});
const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, '');
});
const name = computed(() => {
return props.content;
});
function getContactObject() {
const contactItem = {
name: name.value,
phone_number: `+${rawPhoneNumber.value}`,
};
return contactItem;
}
async function filterContactByNumber(searchCandidate) {
const query = {
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: [searchCandidate],
attribute_model: 'standard',
custom_attribute_type: '',
};
const queryPayload = { payload: [query] };
const contacts = await $store.dispatch('contacts/filter', {
queryPayload,
resetState: false,
});
return contacts.shift();
}
function openContactNewTab(contactId) {
const accountId = window.location.pathname.split('/')[3];
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
window.open(url, '_blank');
}
async function addContact() {
try {
let contact = await filterContactByNumber(rawPhoneNumber);
if (!contact) {
contact = await $store.dispatch('contacts/create', getContactObject());
useAlert(t('CONTACT_FORM.SUCCESS_MESSAGE'));
}
openContactNewTab(contact.id);
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('phone_number')) {
useAlert(t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
}
const action = computed(() => ({
label: t('CONVERSATION.SAVE_CONTACT'),
onClick: addContact,
}));
</script>
<template>
<BaseAttachmentBubble
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-[#D6409F]"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:content="phoneNumber"
:action="formattedPhoneNumber ? action : null"
/>
</template>
@@ -1,109 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
const props = defineProps({
contentAttributes: {
type: String,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const meetingData = computed(() => {
return useCamelCase(props.contentAttributes.data);
});
const isLoading = ref(false);
const dyteAuthToken = ref('');
const meetingLink = computed(() => {
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value);
});
const joinTheCall = async () => {
isLoading.value = true;
try {
const { data: { authResponse: { authToken } = {} } = {} } =
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId);
dyteAuthToken.value = authToken;
} catch (err) {
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally {
isLoading.value = false;
}
};
const leaveTheRoom = () => {
this.dyteAuthToken = '';
};
const action = computed(() => ({
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
onClick: joinTheCall,
}));
</script>
<template>
<BaseAttachmentBubble
icon="i-ph-video-camera-fill"
icon-bg-color="bg-[#2781F6]"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action"
>
<div v-if="dyteAuthToken" class="video-call--container">
<iframe
:src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/>
<button
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
@click="leaveTheRoom"
>
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
</button>
</div>
<div v-else>
{{ '' }}
</div>
</BaseAttachmentBubble>
</template>
<style lang="scss">
.join-call-button {
margin: var(--space-small) 0;
}
.video-call--container {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
z-index: var(--z-index-high);
padding: var(--space-smaller);
background: var(--b-800);
iframe {
width: 100%;
height: 100%;
border: 0;
}
button {
position: absolute;
top: var(--space-smaller);
right: 10rem;
}
}
</style>
@@ -1,98 +0,0 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_STATUS } from '../../constants';
const props = defineProps({
contentAttributes: {
type: Object,
default: () => ({}),
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
sender: {
type: Object,
default: () => ({}),
},
});
const hasError = computed(() => {
return props.status === MESSAGE_STATUS.FAILED;
});
const fromEmail = computed(() => {
return props.contentAttributes?.email?.from ?? [];
});
const toEmail = computed(() => {
return props.contentAttributes?.email?.to ?? [];
});
const ccEmail = computed(() => {
return (
props.contentAttributes?.ccEmails ??
props.contentAttributes?.email?.cc ??
[]
);
});
const senderName = computed(() => {
return props.sender.name ?? '';
});
const bccEmail = computed(() => {
return (
props.contentAttributes?.bccEmails ??
props.contentAttributes?.email?.bcc ??
[]
);
});
const subject = computed(() => {
return props.contentAttributes?.email?.subject ?? '';
});
const showMeta = computed(() => {
return (
fromEmail.value[0] ||
toEmail.value.length ||
ccEmail.value.length ||
bccEmail.value.length ||
subject.value
);
});
</script>
<template>
<section
v-show="showMeta"
class="p-4 space-y-1 pr-9 border-b border-n-strong"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div v-if="fromEmail[0]">
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</div>
<div v-if="toEmail.length">
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
</div>
<div v-if="ccEmail.length">
{{ $t('EMAIL_HEADER.CC') }}:
{{ ccEmail.join(', ') }}
</div>
<div v-if="bccEmail.length">
{{ $t('EMAIL_HEADER.BCC') }}:
{{ bccEmail.join(', ') }}
</div>
<div v-if="subject">
{{ $t('EMAIL_HEADER.SUBJECT') }}:
{{ subject }}
</div>
</template>
</section>
</template>
@@ -1,133 +0,0 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import { MESSAGE_STATUS, MESSAGE_TYPES } from '../../constants';
const props = defineProps({
content: {
type: String,
required: true,
},
contentAttributes: {
type: Object,
default: () => ({}),
},
attachments: {
type: Array,
default: () => [],
},
status: {
type: String,
required: true,
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
sender: {
type: Object,
default: () => ({}),
},
messageType: {
type: Number,
required: true,
},
});
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value.scrollHeight > 400;
});
const isOutgoing = computed(() => {
return props.messageType === MESSAGE_TYPES.OUTGOING;
});
const fullHTML = computed(() => {
return props.contentAttributes?.email?.htmlContent?.full ?? props.content;
});
const unquotedHTML = computed(() => {
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
});
const hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
});
const textToShow = computed(() => {
const text =
props.contentAttributes?.email?.textContent?.full ?? props.content;
return text.replace(/\n/g, '<br>');
});
</script>
<template>
<BaseBubble class="w-full overflow-hidden" data-bubble-name="email">
<EmailMeta :status :sender :content-attributes />
<section
ref="contentContainer"
class="p-4"
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
}"
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 p-8 flex items-end bg-gradient-to-t dark:from-[#24252b] from-[#F5F5F6] dark:via-[rgba(36,37,43,0.5)] via-[rgba(245,245,246,0.50)] dark:to-transparent to-[rgba(245,245,246,0.00)]"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@click="isExpanded = true"
>
<Icon icon="i-lucide-maximize-2" />
{{ $t('EMAIL_HEADER.EXPAND') }}
</button>
</div>
<FormattedContent v-if="isOutgoing && content" :content="content" />
<template v-else>
<Letter
v-if="showQuotedMessage"
class-name="prose prose-email !max-w-none"
:html="fullHTML"
:text="textToShow"
/>
<Letter
v-else
class-name="prose prose-email !max-w-none"
:html="unquotedHTML"
:text="textToShow"
/>
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
/>
</button>
</section>
<section v-if="attachments.length" class="px-4 pb-4 space-y-2">
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
</BaseBubble>
</template>
@@ -1,126 +0,0 @@
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
'.gmail_quote',
'.OutlookQuote',
'.email-quote',
'.quoted-text',
'.quote',
'[class*="quote"]',
'[class*="Quote"]',
];
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
/-----Original Message-----/i,
/Sent: /i,
/From: /i,
];
export class EmailQuoteExtractor {
/**
* Remove quotes from email HTML and return cleaned HTML
* @param {string} htmlContent - Full HTML content of the email
* @returns {string} HTML content with quotes removed
*/
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
tempDiv.querySelectorAll(selector).forEach(el => {
el.remove();
});
});
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
el.remove();
});
return tempDiv.innerHTML;
}
/**
* Check if HTML content contains any quotes
* @param {string} htmlContent - Full HTML content of the email
* @returns {boolean} True if quotes are detected, false otherwise
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
for (const selector of QUOTE_INDICATORS) {
if (tempDiv.querySelector(selector)) {
return true;
}
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
}
/**
* Find text nodes that match quote patterns
* @param {Element} rootElement - Root element to search
* @returns {Element[]} Array of parent block elements containing quote-like text
*/
static findTextNodeQuotes(rootElement) {
const quoteBlocks = [];
const treeWalker = document.createTreeWalker(
rootElement,
NodeFilter.SHOW_TEXT,
null,
false
);
for (
let currentNode = treeWalker.nextNode();
currentNode !== null;
currentNode = treeWalker.nextNode()
) {
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
pattern.test(currentNode.textContent)
);
if (isQuoteLike) {
const parentBlock = this.findParentBlock(currentNode);
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
quoteBlocks.push(parentBlock);
}
}
}
return quoteBlocks;
}
/**
* Find the closest block-level parent element by recursively traversing up the DOM tree.
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
* that contain the text node. It's used to identify and remove entire block-level elements
* that contain quote-like text, rather than just removing the text node itself. This ensures
* proper structural removal of quoted content while maintaining HTML integrity.
* @param {Node} node - Starting node to find parent
* @returns {Element|null} Block-level parent element
*/
static findParentBlock(node) {
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
let current = node.parentElement;
while (current) {
if (blockElements.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
}
}
@@ -1,73 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseAttachmentBubble from './BaseAttachment.vue';
import FileIcon from 'next/icon/FileIcon.vue';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const url = computed(() => {
return props.attachments[0].dataUrl;
});
const fileName = computed(() => {
if (url.value) {
const filename = url.value.substring(url.value.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
});
const fileType = computed(() => {
return fileName.value.split('.').pop();
});
</script>
<template>
<BaseAttachmentBubble
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-n-alpha-3 dark:bg-n-alpha-white"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.FILE"
:content="decodeURI(fileName)"
:action="{
href: url,
label: $t('CONVERSATION.DOWNLOAD'),
}"
>
<template #icon>
<FileIcon :file-type="fileType" class="size-4" />
</template>
</BaseAttachmentBubble>
</template>
@@ -1,111 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from 'next/message/provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['error']);
const attachment = computed(() => {
return props.attachments[0];
});
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
emit('error');
};
const downloadAttachment = async () => {
const response = await fetch(attachment.value.dataUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attachment${attachment.value.extension || ''}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
</script>
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
data-bubble-name="image"
@click="showGallery = true"
>
<div
v-if="hasError"
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<template v-else>
<img
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@click="onClick"
@error="handleError"
/>
<div
class="inset-0 p-2 absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex items-end justify-end gap-1.5"
>
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
<Button
xs
solid
slate
icon="i-lucide-download"
class="opacity-60"
@click="downloadAttachment"
/>
</div>
</template>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleError"
@close="() => (showGallery = false)"
/>
</template>
@@ -1,89 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../constants';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
const props = defineProps({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['error']);
const attachment = computed(() => {
return props.attachments[0];
});
const { variant } = useMessageContext();
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return props.content;
}
return new MessageFormatter(props.content).formattedMessage;
});
const onImageLoadError = () => {
hasImgStoryError.value = true;
emit('error');
};
const onVideoLoadError = () => {
hasVideoStoryError.value = true;
emit('error');
};
</script>
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<div v-if="content" class="mb-2" v-html="formattedContent" />
<img
v-if="!hasImgStoryError"
class="rounded-lg max-w-80"
:src="attachment.dataUrl"
@error="onImageLoadError"
/>
<video
v-else-if="!hasVideoStoryError"
class="rounded-lg max-w-80"
controls
:src="attachment.dataUrl"
@error="onVideoLoadError"
/>
<div
v-else
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
<p class="mb-0 text-n-slate-11">
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
</p>
</div>
</BaseBubble>
</template>
@@ -1,108 +0,0 @@
<script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import BaseAttachmentBubble from './BaseAttachment.vue';
import { useI18n } from 'vue-i18n';
// import maplibregl from 'maplibre-gl';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
sender: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const attachment = computed(() => {
return props.attachments[0];
});
const lat = computed(() => {
return attachment.value.coordinatesLat;
});
const long = computed(() => {
return attachment.value.coordinatesLong;
});
const title = computed(() => {
return attachment.value.fallbackTitle;
});
const mapUrl = computed(
() => `https://maps.google.com/?q=${lat.value},${long.value}`
);
const mapContainer = useTemplateRef('mapContainer');
// const setupMap = () => {
// const map = new maplibregl.Map({
// style: 'https://tiles.openfreemap.org/styles/positron',
// center: [long.value, lat.value],
// zoom: 15,
// container: mapContainer.value,
// attributionControl: false,
// dragPan: false,
// dragRotate: false,
// scrollZoom: false,
// touchZoom: false,
// touchRotate: false,
// keyboard: false,
// doubleClickZoom: false,
// });
// new maplibregl.Marker().setLngLat([long.value, lat.value]).addTo(map);
// return map;
// };
onMounted(async () => {
await nextTick();
// setupMap();
});
</script>
<template>
<BaseAttachmentBubble
icon="i-ph-navigation-arrow-fill"
icon-bg-color="bg-[#0D9B8A]"
:sender="sender"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.LOCATION"
:content="title"
:action="{
label: t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP'),
href: mapUrl,
}"
>
<template #before>
<div
ref="mapContainer"
class="z-10 w-full max-w-md -mb-12 min-w-64 h-28"
/>
</template>
</BaseAttachmentBubble>
</template>
<style>
@import 'maplibre-gl/dist/maplibre-gl.css';
</style>
@@ -1,31 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../../provider.js';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants';
const props = defineProps({
content: {
type: String,
required: true,
},
});
const { variant } = useMessageContext();
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
return props.content;
}
return new MessageFormatter(props.content).formattedMessage;
});
</script>
<template>
<span
v-dompurify-html="formattedContent"
class="[&>p:last-child]:mb-0 [&>ul]:list-inside"
/>
</template>
@@ -1,65 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import { MESSAGE_TYPES } from '../../constants';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
const props = defineProps({
content: {
type: String,
required: true,
},
attachments: {
type: Array,
default: () => [],
},
contentAttributes: {
type: Object,
default: () => ({}),
},
messageType: {
type: Number,
required: true,
validator: value => Object.values(MESSAGE_TYPES).includes(value),
},
});
const isTemplate = computed(() => {
return props.messageType === MESSAGE_TYPES.TEMPLATE;
});
</script>
<template>
<BaseBubble class="flex flex-col gap-3 px-4 py-3" data-bubble-name="text">
<FormattedContent v-if="content" :content="content" />
<AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate">
<div
v-if="contentAttributes.submittedEmail"
class="px-2 py-1 rounded-lg bg-n-alpha-3"
>
{{ contentAttributes.submittedEmail }}
</div>
</template>
</BaseBubble>
</template>
<style>
p:last-child {
margin-bottom: 0;
}
</style>
@@ -1,9 +0,0 @@
<script setup>
import BaseBubble from './Base.vue';
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</BaseBubble>
</template>
@@ -1,84 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import BaseBubble from './Base.vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from 'next/message/provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import { ATTACHMENT_TYPES } from '../constants';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
/**
* @typedef {Object} Props
* @property {Attachment[]} [attachments=[]] - The attachments associated with the message
*/
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['error']);
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
emit('error');
};
const attachment = computed(() => {
return props.attachments[0];
});
const isReel = computed(() => {
return attachment.value.fileType === ATTACHMENT_TYPES.IG_REEL;
});
</script>
<template>
<BaseBubble
class="overflow-hidden relative group border-[4px] border-n-weak"
data-bubble-name="video"
@click="showGallery = true"
>
<div
v-if="isReel"
class="absolute p-2 flex items-start justify-end size-12 bg-gradient-to-bl from-n-alpha-black1 to-transparent right-0"
>
<Icon icon="i-lucide-instagram" class="text-white" />
</div>
<video
controls
:src="attachment.dataUrl"
:class="{
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@error="handleError"
/>
</BaseBubble>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="onError"
@close="() => (showGallery = false)"
/>
</template>
@@ -1,106 +0,0 @@
<script setup>
import { computed, defineOptions, useAttrs } from 'vue';
import ImageChip from 'next/message/chips/Image.vue';
import VideoChip from 'next/message/chips/Video.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import FileChip from 'next/message/chips/File.vue';
import { useMessageContext } from '../provider.js';
import { ATTACHMENT_TYPES } from '../constants';
/**
* @typedef {Object} Attachment
* @property {number} id - Unique identifier for the attachment
* @property {number} messageId - ID of the associated message
* @property {'image'|'audio'|'video'|'file'|'location'|'fallback'|'share'|'story_mention'|'contact'|'ig_reel'} fileType - Type of the attachment (file or image)
* @property {number} accountId - ID of the associated account
* @property {string|null} extension - File extension
* @property {string} dataUrl - URL to access the full attachment data
* @property {string} thumbUrl - URL to access the thumbnail version
* @property {number} fileSize - Size of the file in bytes
* @property {number|null} width - Width of the image if applicable
* @property {number|null} height - Height of the image if applicable
*/
const props = defineProps({
attachments: {
type: Array,
default: () => [],
},
});
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
const { orientation } = useMessageContext();
const classToApply = computed(() => {
const baseClasses = [attrs.class, 'flex', 'flex-wrap'];
if (orientation.value === 'right') {
baseClasses.push('justify-end');
}
return baseClasses;
});
const allAttachments = computed(() => {
return Array.isArray(props.attachments) ? props.attachments : [];
});
const mediaAttachments = computed(() => {
const allowedTypes = [ATTACHMENT_TYPES.IMAGE, ATTACHMENT_TYPES.VIDEO];
const mediaTypes = allAttachments.value.filter(attachment =>
allowedTypes.includes(attachment.fileType)
);
return mediaTypes.sort(
(a, b) =>
allowedTypes.indexOf(a.fileType) - allowedTypes.indexOf(b.fileType)
);
});
const recordings = computed(() => {
return allAttachments.value.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
);
});
const files = computed(() => {
return allAttachments.value.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.FILE
);
});
</script>
<template>
<div v-if="mediaAttachments.length" :class="classToApply">
<template v-for="attachment in mediaAttachments" :key="attachment.id">
<ImageChip
v-if="attachment.fileType === ATTACHMENT_TYPES.IMAGE"
:attachment="attachment"
/>
<VideoChip
v-else-if="attachment.fileType === ATTACHMENT_TYPES.VIDEO"
:attachment="attachment"
/>
</template>
</div>
<div v-if="recordings.length" :class="classToApply">
<div v-for="attachment in recordings" :key="attachment.id">
<AudioChip
class="bg-n-alpha-3 dark:bg-n-alpha-2 text-n-slate-12"
:attachment="attachment"
/>
</div>
</div>
<div v-if="files.length" :class="classToApply">
<FileChip
v-for="attachment in files"
:key="attachment.id"
:attachment="attachment"
/>
</div>
</template>
@@ -1,133 +0,0 @@
<script setup>
import { computed, useTemplateRef, ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
const { attachment } = defineProps({
attachment: {
type: Object,
required: true,
},
});
defineOptions({
inheritAttrs: false,
});
const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl);
});
const audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false);
const isMuted = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const onLoadedMetadata = () => {
duration.value = audioPlayer.value.duration;
};
const formatTime = time => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const toggleMute = () => {
audioPlayer.value.muted = !audioPlayer.value.muted;
isMuted.value = audioPlayer.value.muted;
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const downloadAudio = async () => {
const response = await fetch(timeStampURL.value);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
const filename = timeStampURL.value.split('/').pop().split('?')[0] || 'audio';
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
};
</script>
<template>
<audio
ref="audioPlayer"
controls
class="hidden"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="timeStampURL" />
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex items-center px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
</template>
@@ -1,72 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import FileIcon from 'next/icon/FileIcon.vue';
import Icon from 'next/icon/Icon.vue';
const { attachment } = defineProps({
attachment: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const fileName = computed(() => {
const url = attachment.dataUrl;
if (url) {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
});
const fileType = computed(() => {
return fileName.value.split('.').pop();
});
const textColorClass = computed(() => {
const colorMap = {
'7z': 'dark:text-[#EDEEF0] text-[#2F265F]',
csv: 'text-amber-12',
doc: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
json: 'text-n-slate-12',
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
pdf: 'text-n-ruby-12',
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
rtf: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
tar: 'dark:text-[#EDEEF0] text-[#2F265F]',
txt: 'text-n-slate-12',
xls: 'text-n-teal-12',
xlsx: 'text-n-teal-12',
zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
};
return colorMap[fileType.value] || 'text-n-slate-12';
});
</script>
<template>
<div
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-strong"
>
<FileIcon class="flex-shrink-0" :file-type="fileType" />
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
{{ fileName }}
</span>
<a
v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
:href="url"
rel="noreferrer noopener nofollow"
target="_blank"
>
<Icon icon="i-lucide-download" />
</a>
</div>
</template>
@@ -1,52 +0,0 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
defineProps({
attachment: {
type: Object,
required: true,
},
});
const hasError = ref(false);
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
const handleError = () => {
hasError.value = true;
};
</script>
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
@click="showGallery = true"
>
<div
v-if="hasError"
class="flex flex-col items-center justify-center gap-1 text-xs text-center rounded-lg size-full bg-n-alpha-1 text-n-slate-11"
>
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
{{ $t('COMPONENTS.MEDIA.LOADING_FAILED') }}
</div>
<img
v-else
class="object-cover w-full h-full"
:src="attachment.dataUrl"
@error="handleError"
/>
</div>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="handleError"
@close="() => (showGallery = false)"
/>
</template>
@@ -1,52 +0,0 @@
<script setup>
import { ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
defineProps({
attachment: {
type: Object,
required: true,
},
});
const showGallery = ref(false);
const { filteredCurrentChatAttachments } = useMessageContext();
</script>
<template>
<div
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
@click="showGallery = true"
>
<video
:src="attachment.dataUrl"
class="w-full h-full object-cover"
muted
playsInline
/>
<div
class="absolute w-full h-full inset-0 p-1 flex items-center justify-center"
>
<div
class="size-7 bg-n-slate-1/60 backdrop-blur-sm rounded-full overflow-hidden shadow-[0_5px_15px_rgba(0,0,0,0.4)]"
>
<Icon
icon="i-teenyicons-play-small-solid"
class="size-7 text-n-slate-12/80 backdrop-blur"
/>
</div>
</div>
</div>
<GalleryView
v-if="showGallery"
v-model:show="showGallery"
:attachment="useSnakeCase(attachment)"
:all-attachments="filteredCurrentChatAttachments"
@error="onError"
@close="() => (showGallery = false)"
/>
</template>
@@ -1,72 +0,0 @@
export const MESSAGE_TYPES = {
INCOMING: 0,
OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
};
export const MESSAGE_VARIANTS = {
USER: 'user',
AGENT: 'agent',
ACTIVITY: 'activity',
PRIVATE: 'private',
BOT: 'bot',
ERROR: 'error',
TEMPLATE: 'template',
EMAIL: 'email',
UNSUPPORTED: 'unsupported',
};
export const SENDER_TYPES = {
CONTACT: 'Contact',
USER: 'User',
};
export const ORIENTATION = {
LEFT: 'left',
RIGHT: 'right',
CENTER: 'center',
};
export const MESSAGE_STATUS = {
SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
FAILED: 'failed',
PROGRESS: 'progress',
};
export const ATTACHMENT_TYPES = {
IMAGE: 'image',
AUDIO: 'audio',
VIDEO: 'video',
FILE: 'file',
LOCATION: 'location',
FALLBACK: 'fallback',
SHARE: 'share',
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
};
export const CONTENT_TYPES = {
TEXT: 'text',
INPUT_TEXT: 'input_text',
INPUT_TEXTAREA: 'input_textarea',
INPUT_EMAIL: 'input_email',
INPUT_SELECT: 'input_select',
CARDS: 'cards',
FORM: 'form',
ARTICLE: 'article',
INCOMING_EMAIL: 'incoming_email',
INPUT_CSAT: 'input_csat',
INTEGRATIONS: 'integrations',
STICKER: 'sticker',
};
export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IMAGE,
ATTACHMENT_TYPES.VIDEO,
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
@@ -1,382 +0,0 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 60913,
content:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
inbox_id: 992,
conversation_id: 134,
message_type: 0,
content_type: 'incoming_email',
status: 'sent',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/mixed; boundary=00000000000098e88e0628704c8b',
date: '2024-12-04T17:13:53+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr"><p>Dear Sam,</p><p>We are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?</p><p>Looking forward to your response.</p><p>Best regards,<br>Alex<br>T-Shirt Co.</p></div>\n',
reply:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
quoted:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production. Please find attached a document with our specifications and requirements. Could you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
},
in_reply_to: null,
message_id:
'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
multipart: true,
number_of_attachments: 2,
subject: 'Inquiry and Quotation for Cotton Fabric',
text_content: {
full: 'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.\n',
reply:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
quoted:
'Dear Sam,\n\nWe are looking for high-quality cotton fabric for our T-shirt production.\nPlease find attached a document with our specifications and requirements.\nCould you provide us with a quotation and lead time?\n\nLooking forward to your response.\n\nBest regards,\nAlex\nT-Shirt Co.',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
created_at: 1733312661,
private: false,
source_id: 'CAM_Qp+-tdJ2Muy4XZmQfYKOPzsFwrH5H=6j=snsFZEDw@mail.gmail.com',
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
attachments: [
{
id: 826,
message_id: 60913,
file_type: 'file',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/requirements.pdf',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdFdKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--10170e22f42401a9259e17eba6e59877127353d0/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/requirements.pdf',
file_size: 841909,
width: null,
height: null,
},
{
id: 18,
message_id: 5307,
file_type: 'file',
account_id: 2,
extension: null,
data_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/quantity-requirements.xls',
thumb_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaUVLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4f7e671db635b73d12ee004e87608bc098ef6b3b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--5c454d5f03daf1f9f4068cb242cf9885cc1815b6/all-files.zip',
file_size: 99844,
width: null,
height: null,
},
],
},
{
id: 60914,
content:
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 1,
created_at: 1733312726,
updated_at: '2024-12-04T11:45:34.451Z',
private: false,
status: 'sent',
source_id:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Dear Alex,\r\n\r\nThank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.\r\n\r\nBest regards, \r\nSam \r\nFabricMart',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 0,
last_activity_at: 1733312726,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
attachments: [
{
id: 827,
message_id: 60914,
file_type: 'file',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9UY21WemFYcGxYM1J2WDJacGJHeGJCMmtCK2pBPSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--31a6ed995cc4ac2dd2fa023068ee23b23efa1efb/quotation.pdf',
file_size: 841909,
width: null,
height: null,
},
],
sender: {
id: 110,
name: 'Alex',
available_name: 'Alex',
avatar_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
type: 'user',
availability_status: 'online',
thumbnail:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
},
},
{
id: 60915,
content:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 0,
created_at: 1733312835,
updated_at: '2024-12-04T11:47:15.876Z',
private: false,
status: 'sent',
source_id: 'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/alternative; boundary=0000000000007191be06287054c4',
date: '2024-12-04T17:16:07+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr"><p>Dear Sam,</p><p>Thank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?</p><p>Best,<br>Alex</p></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:15, Sam from CottonMart &lt;<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>&gt; wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Thank you for your inquiry. Please find attached our quotation based on your requirements. Let us know if you need further details or wish to discuss specific customizations.</p>\n<p>Best regards,<br>\nSam<br>\nFabricMart</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf" target="_blank">click here to view</a>]\n</blockquote></div>\n',
reply:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
quoted:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the fabric for us to review before proceeding?\n\nBest,\nAlex',
},
in_reply_to:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60914@reply.chatwoot.dev',
message_id:
'CAM_Qp+_70EiYJ_nKMgJ6MZaD58Tq3E57QERcZgnd10g@mail.gmail.com',
multipart: true,
number_of_attachments: 0,
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
text_content: {
full: 'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test>\nwrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>\n',
reply:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex\n\nOn Wed, 4 Dec 2024 at 17:15, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Thank you for your inquiry. Please find attached our quotation based on\n> your requirements. Let us know if you need further details or wish to\n> discuss specific customizations.\n>\n> Best regards,\n> Sam\n> FabricMart\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGFKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--940f9c3df19ce042ef3447809c9c451cfa4e905b/quotation.pdf>]\n>',
quoted:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Dear Sam,\n\nThank you for the quotation. Could you share images or samples of the\nfabric for us to review before proceeding?\n\nBest,\nAlex',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 1,
last_activity_at: 1733312835,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
message_type: 1,
content_type: 'text',
source_id:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
processed_message_content:
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
id: 60916,
content:
"Dear Alex,\r\n\r\nPlease find attached images of our cotton fabric samples. Let us know if you'd like physical samples sent to you. \r\n\r\nWarm regards, \r\nSam",
account_id: 51,
inbox_id: 992,
conversation_id: 134,
created_at: 1733312866,
updated_at: '2024-12-04T11:47:53.564Z',
private: false,
status: 'sent',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 0,
last_activity_at: 1733312866,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
attachments: [
{
id: 828,
message_id: 60916,
file_type: 'image',
account_id: 51,
extension: null,
data_url:
'https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png',
thumb_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/image.png',
file_size: 1617507,
width: 1600,
height: 900,
},
],
sender: {
id: 110,
name: 'Alex',
available_name: 'Alex',
avatar_url:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
type: 'user',
availability_status: 'online',
thumbnail:
'https://staging.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBbktJIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--25806e8b52810484d3d6cb53af9e2a1c0cf1b43d/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--988d66f5e450207265d5c21bb0edb3facb890a43/slick-deploy.png',
},
previous_changes: {
updated_at: ['2024-12-04T11:47:46.879Z', '2024-12-04T11:47:53.564Z'],
source_id: [
null,
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
],
},
},
{
id: 60917,
content:
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
account_id: 51,
inbox_id: 992,
conversation_id: 134,
message_type: 0,
created_at: 1733312969,
updated_at: '2024-12-04T11:49:29.337Z',
private: false,
status: 'sent',
source_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/related; boundary=0000000000007701030628705e31',
date: '2024-12-04T17:18:54+05:30',
from: ['alex@paperlayer.test'],
html_content: {
full: '<div dir="ltr">Great we were looking for something in a different finish see image attached<br><br><img src="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGlKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--408309fa40f1cfea87ee3320a062a5d16ce09d4e/image.png" alt="image.png" width="472" height="305"><br><div><br></div><div>Let me know if you have different finish options?<br><br>Best Regards</div></div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Wed, 4 Dec 2024 at 17:17, Sam from CottonMart &lt;<a href="mailto:sam@cottonmart.test">sam@cottonmart.test</a>&gt; wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <p>Dear Alex,</p>\n<p>Please find attached images of our cotton fabric samples. Let us know if you&#39;d like physical samples sent to you.</p>\n<p>Warm regards,<br>\nSam</p>\n\n attachment [<a href="https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png" target="_blank">click here to view</a>]\n</blockquote></div>\n',
reply:
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n>',
quoted:
'Great we were looking for something in a different finish see image attached\n\n[image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
},
in_reply_to:
'conversation/758d1f24-dc76-4abc-9c41-255ed8974f8e/messages/60916@reply.chatwoot.dev',
message_id: 'CAM_Qp+8LuzLTWZXkecjzJAgmb9RAQGm+qTmg@mail.gmail.com',
multipart: true,
number_of_attachments: 1,
subject: 'Re: Inquiry and Quotation for Cotton Fabric',
text_content: {
full: "Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
reply:
"Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards\n\nOn Wed, 4 Dec 2024 at 17:17, Sam from CottonMart <sam@cottonmart.test> wrote:\n\n> Dear Alex,\n>\n> Please find attached images of our cotton fabric samples. Let us know if\n> you'd like physical samples sent to you.\n>\n> Warm regards,\n> Sam\n> attachment [click here to view\n> <https://staging.chatwoot.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBdGVKIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--62ee3b99421bfe7d8db85959ae99ab03a899f351/image.png>]\n>",
quoted:
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
},
to: ['sam@cottonmart.test'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Great we were looking for something in a different finish see image attached\n\n[image: image.png]\n\nLet me know if you have different finish options?\n\nBest Regards',
sentiment: {},
conversation: {
assignee_id: 110,
unread_count: 1,
last_activity_at: 1733312969,
contact_inbox: {
source_id: 'alex@paperlayer.test',
},
},
sender: {
additional_attributes: {
source_id: 'email:CAM_Qp+8beyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'alex@paperlayer.test',
id: 111256,
identifier: null,
name: 'Alex',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
],
{ deep: true }
);
File diff suppressed because one or more lines are too long
@@ -1,85 +0,0 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 60716,
content:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
account_id: 51,
inbox_id: 991,
conversation_id: 46,
message_type: 0,
created_at: 1733141025,
updated_at: '2024-12-02T12:03:45.663Z',
private: false,
status: 'sent',
source_id:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
content_type: 'incoming_email',
content_attributes: {
email: {
bcc: null,
cc: null,
content_type:
'multipart/alternative; boundary=0000000000009d889e0628477235',
date: '2024-12-02T16:29:39+05:30',
from: ['hey@shivam.dev'],
html_content: {
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features weve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We&#39;ve tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDont forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Heres what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our teams unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Heres a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHeres what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our teams unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.',
},
in_reply_to: null,
message_id:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
multipart: true,
number_of_attachments: 0,
subject: 'Update on Chatwoot Integration and Features',
text_content: {
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features weve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDont forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHeres what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our teams unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHeres a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
},
to: ['shivam@chatwoot.com'],
},
cc_email: null,
bcc_email: null,
},
sender_type: 'Contact',
sender_id: 111256,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1733141025,
contact_inbox: {
source_id: 'hey@shivam.dev',
},
},
sender: {
additional_attributes: {
source_id:
'email:CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
},
custom_attributes: {},
email: 'hey@shivam.dev',
id: 111256,
identifier: null,
name: 'Shivam Mishra',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
],
{ deep: true }
);
@@ -1,715 +0,0 @@
import camelcaseKeys from 'camelcase-keys';
export default camelcaseKeys(
[
{
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inbox_id: 475,
conversation_id: 43,
message_type: 0,
content_type: 'text',
status: 'sent',
content_attributes: {
in_reply_to: null,
},
created_at: 1732195656,
private: false,
source_id: null,
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5273,
content: 'Give the team a way to reach you.',
inbox_id: 475,
conversation_id: 43,
message_type: 3,
content_type: 'text',
status: 'read',
content_attributes: {},
created_at: 1732195656,
private: false,
source_id: null,
},
{
id: 5274,
content: 'Get notified by email',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 3,
created_at: 1732195656,
updated_at: '2024-11-21T13:27:53.612Z',
private: false,
status: 'read',
source_id: null,
content_type: 'input_email',
content_attributes: {
submitted_email: 'hey@example.com',
},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Get notified by email',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1732195656,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5275,
content:
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195735,
updated_at: '2024-11-21T13:28:55.508Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Does the Startup plan include the two users from the Free plan, or do I have to buy those separately?',
sentiment: {},
conversation: {
assignee_id: null,
unread_count: 1,
last_activity_at: 1732195735,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content: 'John self-assigned this conversation',
id: 5276,
content: 'John self-assigned this conversation',
account_id: 1,
inbox_id: 475,
message_type: 2,
created_at: 1732195741,
updated_at: '2024-11-21T13:30:26.788Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195826,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
previous_changes: {
updated_at: ['2024-11-21T13:29:01.570Z', '2024-11-21T13:30:26.788Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
id: 5277,
content:
'Hey thanks for your interest in upgrading, no, the seats are not included, you will have to purchase them alongside the rest. How many seats are you planning to upgrade to?',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195826,
updated_at: '2024-11-21T13:30:26.837Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195826,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:30:26.149Z', '2024-11-21T13:30:26.837Z'],
status: ['sent', 'read'],
},
},
{
id: 5278,
content: "Oh, that's unfortunate",
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195820,
updated_at: '2024-11-21T13:30:38.070Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: "Oh, that's unfortunate",
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195820,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5279,
content:
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195820,
updated_at: '2024-11-21T13:31:05.284Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'I plan to upgrade to 4 agents for now, but will grow to 6 in the next three months. ',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195885,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5280,
content: 'Is it possible to get a discount?',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195886,
updated_at: '2024-11-21T13:31:12.545Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Is it possible to get a discount?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195872,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
id: 5281,
content:
'[@Bruce](mention://user/30/Bruce) should we offer them a discount',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195887,
updated_at: '2024-11-21T13:32:59.863Z',
private: true,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195972,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:31:27.914Z', '2024-11-21T13:32:59.863Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'read',
content_type: 'text',
processed_message_content:
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
id: 5282,
content:
'Sure, you can use the discount code KQS3242A at the checkout to get 30% off on your yearly subscription. This coupon only applies for a year, I hope this helps',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195972,
updated_at: '2024-11-21T13:32:59.902Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195972,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:32:52.722Z', '2024-11-21T13:32:59.902Z'],
status: ['sent', 'read'],
},
},
{
id: 5283,
content: 'Great, thanks',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195982,
updated_at: '2024-11-21T13:33:02.142Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Great, thanks',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195982,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
id: 5284,
content: 'Really appreciate it',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 0,
created_at: 1732195984,
updated_at: '2024-11-21T13:33:04.856Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {
in_reply_to: null,
},
sender_type: 'Contact',
sender_id: 597,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Really appreciate it',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 1,
last_activity_at: 1732195984,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
additional_attributes: {},
custom_attributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'hey',
phone_number: null,
thumbnail: '',
type: 'contact',
},
},
{
conversation_id: 43,
status: 'progress',
content_type: 'text',
processed_message_content: ' Happy to help :)',
id: 5285,
content: ' Happy to help :)',
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732195991,
updated_at: '2024-11-21T13:33:12.229Z',
private: false,
source_id: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732195991,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:33:11.667Z', '2024-11-21T13:33:12.229Z'],
status: ['sent', 'read'],
},
},
{
conversation_id: 43,
status: 'failed',
content_type: 'text',
processed_message_content:
"Let us know if you have any questions, I'll close this conversation for now",
id: 5286,
content:
"Let us know if you have any questions, I'll close this conversation for now",
account_id: 1,
inbox_id: 475,
message_type: 1,
created_at: 1732196013,
updated_at: '2024-11-21T13:33:33.879Z',
private: false,
source_id: null,
content_attributes: {
external_error:
'Business account is restricted from messaging users in this country.',
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196013,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
sender: {
id: 1,
name: 'John',
available_name: 'John',
avatar_url:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
type: 'user',
availability_status: null,
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBaDBLIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4e625d80e7ef2dc41354392bc214832fbe640840/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/picologo.png',
},
previous_changes: {
updated_at: ['2024-11-21T13:33:33.511Z', '2024-11-21T13:33:33.879Z'],
status: ['sent', 'read'],
},
},
{
id: 5287,
content: 'John set the priority to urgent',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196017,
updated_at: '2024-11-21T13:33:37.569Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John set the priority to urgent',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196017,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5288,
content: 'John added billing',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196020,
updated_at: '2024-11-21T13:33:40.207Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John added billing',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196020,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5289,
content: 'John added delivery',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196020,
updated_at: '2024-11-21T13:33:40.822Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'John added delivery',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196020,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
{
id: 5290,
content: 'Conversation was marked resolved by John',
account_id: 1,
inbox_id: 475,
conversation_id: 43,
message_type: 2,
created_at: 1732196029,
updated_at: '2024-11-21T13:33:49.059Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Conversation was marked resolved by John',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1732196029,
contact_inbox: {
source_id: 'b018c554-8e17-4102-8a0b-f6d20d021017',
},
},
},
],
{ deep: true }
);
File diff suppressed because it is too large Load Diff
@@ -1,33 +0,0 @@
import { inject, provide, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { ATTACHMENT_TYPES } from './constants';
const MessageControl = Symbol('MessageControl');
export function useMessageContext() {
const context = inject(MessageControl, null);
if (context === null) {
throw new Error(`Component is missing a parent <Message /> component.`);
}
const currentChatAttachments = useMapGetter('getSelectedChatAttachments');
const filteredCurrentChatAttachments = computed(() => {
const attachments = currentChatAttachments.value.filter(attachment =>
[
ATTACHMENT_TYPES.IMAGE,
ATTACHMENT_TYPES.VIDEO,
ATTACHMENT_TYPES.IG_REEL,
ATTACHMENT_TYPES.AUDIO,
].includes(attachment.file_type)
);
return useSnakeCase(attachments);
});
return { ...context, filteredCurrentChatAttachments };
}
export function provideMessageContext(context) {
provide(MessageControl, context);
}
@@ -1,45 +0,0 @@
<script setup>
import Message from '../Message.vue';
import simpleEmail from '../fixtures/simpleEmail.js';
import fullConversation from '../fixtures/emailConversation.js';
import newsletterEmail from '../fixtures/newsletterEmail.js';
const failedEmail = {
...simpleEmail[0],
status: 'failed',
senderId: 1,
senderType: 'User',
contentAttributes: {
...simpleEmail[0].contentAttributes,
externalError: 'Failed to send email',
},
};
</script>
<template>
<Story
title="Components/Messages/Email"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Simple Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="message in fullConversation" :key="message.id">
<Message :current-user-id="1" is-email-inbox v-bind="message" />
</template>
</div>
</Variant>
<Variant title="Newsletter">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="message in newsletterEmail" :key="message.id">
<Message :current-user-id="1" is-email-inbox v-bind="message" />
</template>
</div>
</Variant>
<Variant title="Failed Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" is-email-inbox v-bind="failedEmail" />
</div>
</Variant>
</Story>
</template>
@@ -1,137 +0,0 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const instagramStory = computed(() =>
getMessage({
content: 'cwtestinglocal mentioned you in the story: ',
contentAttributes: {
imageType: 'story_mention',
},
attachments: [
getAttachment(
'image',
'https://images.pexels.com/photos/2587370/pexels-photo-2587370.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=2'
),
],
...baseSenderData.value,
})
);
const unsupported = computed(() =>
getMessage({
content: null,
contentAttributes: {
isUnsupported: true,
},
...baseSenderData.value,
})
);
const igReel = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'ig_reel',
'https://videos.pexels.com/video-files/2023708/2023708-hd_720_1280_30fps.mp4'
),
],
...baseSenderData.value,
})
);
</script>
<template>
<Story
title="Components/Message Bubbles/Instagram"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Instagram Reel">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="igReel" />
</div>
</Variant>
<Variant title="Instagram Story">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="instagramStory" />
</div>
</Variant>
<Variant title="Unsupported">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="unsupported" />
</div>
</Variant>
</Story>
</template>
@@ -1,44 +0,0 @@
<script setup>
import Message from '../Message.vue';
import instagramConversation from '../fixtures/instagramConversation.js';
const messages = instagramConversation;
const shouldGroupWithNext = index => {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
const getReplyToMessage = message => {
const idToCheck = message.contentAttributes.inReplyTo;
if (!idToCheck) return null;
return messages.find(candidate => idToCheck === candidate.id);
};
</script>
<template>
<Story title="Components/Messages/Instagram" :layout="{ type: 'single' }">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="(message, index) in messages" :key="message.id">
<Message
:current-user-id="1"
:group-with-next="shouldGroupWithNext(index)"
:in-reply-to="getReplyToMessage(message)"
v-bind="message"
/>
</template>
</div>
</Story>
</template>
@@ -1,260 +0,0 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const audioMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
const brokenImageMessage = computed(() =>
getMessage({
content: null,
attachments: [getAttachment('image', 'https://chatwoot.dev/broken.png')],
...baseSenderData.value,
})
);
const imageMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
],
...baseSenderData.value,
})
);
const videoMessage = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
],
...baseSenderData.value,
})
);
const attachmentsOnly = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('image', 'https://chatwoot.dev/broken.png'),
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
const singleFile = computed(() =>
getMessage({
content: null,
attachments: [getAttachment('file', 'https://chatwoot.dev/all-files.tar')],
...baseSenderData.value,
})
);
const contact = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('contact', null, {
fallbackTitle: '+919999999999',
}),
],
...baseSenderData.value,
})
);
const location = computed(() =>
getMessage({
content: null,
attachments: [
getAttachment('location', null, {
coordinatesLat: 37.7937545,
coordinatesLong: -122.3997472,
fallbackTitle: 'Chatwoot Inc',
}),
],
...baseSenderData.value,
})
);
const dyte = computed(() => {
return getMessage({
messageType: 1,
contentType: 'integrations',
contentAttributes: {
type: 'dyte',
data: {
meetingId: 'f16bebe6-08b9-4593-899a-849f59c47397',
roomName: 'zcufnc-adbjcg',
},
},
senderId: 1,
sender: {
id: 1,
name: 'Shivam Mishra',
availableName: 'Shivam Mishra',
type: 'user',
},
});
});
</script>
<template>
<Story
title="Components/Message Bubbles/Media"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Media Types -->
<Variant title="Audio">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="audioMessage" />
</div>
</Variant>
<Variant title="Image">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="imageMessage" />
</div>
</Variant>
<Variant title="Broken Image">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="brokenImageMessage" />
</div>
</Variant>
<Variant title="Video">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="videoMessage" />
</div>
</Variant>
<!-- Files and Attachments -->
<Variant title="Multiple Attachments">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="attachmentsOnly" />
</div>
</Variant>
<Variant title="File">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="singleFile" />
</div>
</Variant>
<Variant title="Contact">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="contact" />
</div>
</Variant>
<Variant title="Location">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="location" />
</div>
</Variant>
<Variant title="Dyte Video">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="dyte" />
</div>
</Variant>
</Story>
</template>
@@ -1,181 +0,0 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import Message from '../Message.vue';
const currentUserId = ref(1);
const state = reactive({
useCurrentUserId: false,
});
const getMessage = overrides => {
const contentAttributes = {
inReplyTo: null,
...(overrides.contentAttributes ?? {}),
};
const sender = {
additionalAttributes: {},
customAttributes: {},
email: 'hey@example.com',
id: 597,
identifier: null,
name: 'John Doe',
phoneNumber: null,
thumbnail: '',
type: 'contact',
...(overrides.sender ?? {}),
};
return {
id: 5272,
content: 'Hey, how are ya, I had a few questions about Chatwoot?',
inboxId: 475,
conversationId: 43,
messageType: 0,
contentType: 'text',
status: 'sent',
createdAt: 1732195656,
private: false,
sourceId: null,
...overrides,
sender,
contentAttributes,
};
};
const getAttachment = (type, url, overrides) => {
return {
id: 22,
messageId: 5319,
fileType: type,
accountId: 2,
extension: null,
dataUrl: url,
thumbUrl: '',
fileSize: 345644,
width: null,
height: null,
...overrides,
};
};
const baseSenderData = computed(() => {
return {
messageType: state.useCurrentUserId ? 1 : 0,
senderId: state.useCurrentUserId ? currentUserId.value : 597,
sender: {
id: state.useCurrentUserId ? currentUserId.value : 597,
type: state.useCurrentUserId ? 'User' : 'Contact',
},
};
});
const simpleText = computed(() =>
getMessage({
...baseSenderData.value,
})
);
const privateText = computed(() =>
getMessage({ private: true, ...baseSenderData.value })
);
const activityMessage = computed(() =>
getMessage({
content: 'John self-assigned this conversation',
messageType: 2,
})
);
const email = computed(() =>
getMessage({
content: null,
contentType: 'incoming_email',
contentAttributes: {
email: {
bcc: null,
cc: null,
contentType:
'multipart/alternative; boundary=0000000000009d889e0628477235',
date: '2024-12-02T16:29:39+05:30',
from: ['hey@shivam.dev'],
htmlContent: {
full: '<div dir="ltr"><h3><span style="font-size:small;font-weight:normal">Hi Team,</span></h3>\r\n<p>I hope this email finds you well! I wanted to share some updates regarding our integration with <strong>Chatwoot</strong> and outline some key features weve explored.</p>\r\n<hr>\r\n<h3>Key Updates</h3>\r\n<ol>\r\n<li>\r\n<p><strong>Integration Status</strong>:<br>\r\nThe initial integration with Chatwoot has been successful. We&#39;ve tested:</p>\r\n<ul>\r\n<li>API connectivity</li>\r\n<li>Multi-channel messaging</li>\r\n<li>Real-time chat updates</li>\r\n</ul>\r\n</li>\r\n<li>\r\n<p><strong>Upcoming Tasks</strong>:</p>\r\n<ul>\r\n<li>Streamlining notification workflows</li>\r\n<li>Enhancing webhook reliability</li>\r\n<li>Testing team collaboration features</li>\r\n</ul>\r\n</li>\r\n</ol>\r\n<blockquote>\r\n<p><strong>Note:</strong><br>\r\nDont forget to check out the automation capabilities in Chatwoot for handling repetitive queries. It can save a ton of time!</p>\r\n</blockquote>\r\n<hr>\r\n<h3>Features We Love</h3>\r\n<p>Heres what stood out so far:</p>\r\n<ul>\r\n<li><strong>Unified Inbox</strong>: All customer conversations in one place.</li>\r\n<li><strong>Customizable Workflows</strong>: Tailored to our teams unique needs.</li>\r\n<li><strong>Integrations</strong>: Works seamlessly with CRM and Slack.</li>\r\n</ul>\r\n<hr>\r\n<h3>Action Items</h3>\r\n<h4>For Next Week:</h4>\r\n<ol>\r\n<li>Implement the webhook for <strong>ticket prioritization</strong>.</li>\r\n<li>Test <strong>CSAT surveys</strong> post-chat sessions.</li>\r\n<li>Review <strong>analytics dashboard</strong> insights.</li>\r\n</ol>\r\n<hr>\r\n<h3>Data Snapshot</h3>\r\n<p>Heres a quick overview of our conversation stats this week:</p>\r\n<table>\r\n<thead>\r\n<tr>\r\n<th>Metric</th>\r\n<th>Value</th>\r\n<th>Change (%)</th>\r\n</tr>\r\n</thead>\r\n<tbody>\r\n<tr>\r\n<td>Total Conversations</td>\r\n<td>350</td>\r\n<td>+25%</td>\r\n</tr>\r\n<tr>\r\n<td>Average Response Time</td>\r\n<td>3 minutes</td>\r\n<td>-15%</td>\r\n</tr>\r\n<tr>\r\n<td>CSAT Score</td>\r\n<td>92%</td>\r\n<td>+10%</td>\r\n</tr>\r\n</tbody>\r\n</table>\r\n<hr>\r\n<h3>Feedback</h3>\r\n<p><i>Do let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:</i></p>\r\n<p><img src="https://via.placeholder.com/600x300" alt="Chatwoot Dashboard Screenshot" title="Chatwoot Dashboard"></p>\r\n<hr>\r\n<p>Looking forward to hearing your thoughts!</p>\r\n<p>Best regards,<br>~ Shivam Mishra<br></p></div>\r\n',
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.\n\n---------------------------------------------------------------\n\nKey Updates\n\n-\n\nIntegration Status:\nThe initial integration with Chatwoot has been successful. We've tested:\n\n- API connectivity\n- Multi-channel messaging\n- Real-time chat updates\n\n-\n\nUpcoming Tasks:\n\n- Streamlining notification workflows\n- Enhancing webhook reliability\n- Testing team collaboration features\n\n>\n---------------------------------------------------------------\n\nFeatures We Love\n\nHeres what stood out so far:\n\n- Unified Inbox: All customer conversations in one place.\n- Customizable Workflows: Tailored to our teams unique needs.\n- Integrations: Works seamlessly with CRM and Slack.\n\n---------------------------------------------------------------\n\nAction Items\n\nFor Next Week:\n\n- Implement the webhook for ticket prioritization.\n- Test CSAT surveys post-chat sessions.\n- Review analytics dashboard insights.\n\n---------------------------------------------------------------\n\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\n\nMetric\tValue\tChange (%)\nTotal Conversations\t350\t+25%\nAverage Response Time\t3 minutes\t-15%\nCSAT Score\t92%\t+10%\n---------------------------------------------------------------\n\nFeedback\n\nDo let me know if you have additional feedback or ideas to improve our workflows. Heres an image of how our Chatwoot dashboard looks with recent changes:\n\n[Chatwoot Dashboard]\n\n---------------------------------------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding our integration with Chatwoot and outline some key features weve explored.',
},
inReplyTo: null,
messageId:
'CAM_Qp+8bpiT5xFL7HmVL4a9RD0TmdYw7Lu6ZV02yu=eyon41DA@mail.gmail.com',
multipart: true,
numberOfAttachments: 0,
subject: 'Update on Chatwoot Integration and Features',
textContent: {
full: "Hi Team,\r\n\r\nI hope this email finds you well! I wanted to share some updates regarding\r\nour integration with *Chatwoot* and outline some key features weve\r\nexplored.\r\n------------------------------\r\nKey Updates\r\n\r\n 1.\r\n\r\n *Integration Status*:\r\n The initial integration with Chatwoot has been successful. We've tested:\r\n - API connectivity\r\n - Multi-channel messaging\r\n - Real-time chat updates\r\n 2.\r\n\r\n *Upcoming Tasks*:\r\n - Streamlining notification workflows\r\n - Enhancing webhook reliability\r\n - Testing team collaboration features\r\n\r\n*Note:*\r\nDont forget to check out the automation capabilities in Chatwoot for\r\nhandling repetitive queries. It can save a ton of time!\r\n\r\n------------------------------\r\nFeatures We Love\r\n\r\nHeres what stood out so far:\r\n\r\n - *Unified Inbox*: All customer conversations in one place.\r\n - *Customizable Workflows*: Tailored to our teams unique needs.\r\n - *Integrations*: Works seamlessly with CRM and Slack.\r\n\r\n------------------------------\r\nAction Items For Next Week:\r\n\r\n 1. Implement the webhook for *ticket prioritization*.\r\n 2. Test *CSAT surveys* post-chat sessions.\r\n 3. Review *analytics dashboard* insights.\r\n\r\n------------------------------\r\nData Snapshot\r\n\r\nHeres a quick overview of our conversation stats this week:\r\nMetric Value Change (%)\r\nTotal Conversations 350 +25%\r\nAverage Response Time 3 minutes -15%\r\nCSAT Score 92% +10%\r\n------------------------------\r\nFeedback\r\n\r\n*Do let me know if you have additional feedback or ideas to improve our\r\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\r\nchanges:*\r\n\r\n[image: Chatwoot Dashboard Screenshot]\r\n------------------------------\r\n\r\nLooking forward to hearing your thoughts!\r\n\r\nBest regards,\r\n~ Shivam Mishra\r\n",
reply:
"Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.\n------------------------------\nKey Updates\n\n 1.\n\n *Integration Status*:\n The initial integration with Chatwoot has been successful. We've tested:\n - API connectivity\n - Multi-channel messaging\n - Real-time chat updates\n 2.\n\n *Upcoming Tasks*:\n - Streamlining notification workflows\n - Enhancing webhook reliability\n - Testing team collaboration features\n\n*Note:*\nDont forget to check out the automation capabilities in Chatwoot for\nhandling repetitive queries. It can save a ton of time!\n\n------------------------------\nFeatures We Love\n\nHeres what stood out so far:\n\n - *Unified Inbox*: All customer conversations in one place.\n - *Customizable Workflows*: Tailored to our teams unique needs.\n - *Integrations*: Works seamlessly with CRM and Slack.\n\n------------------------------\nAction Items For Next Week:\n\n 1. Implement the webhook for *ticket prioritization*.\n 2. Test *CSAT surveys* post-chat sessions.\n 3. Review *analytics dashboard* insights.\n\n------------------------------\nData Snapshot\n\nHeres a quick overview of our conversation stats this week:\nMetric Value Change (%)\nTotal Conversations 350 +25%\nAverage Response Time 3 minutes -15%\nCSAT Score 92% +10%\n------------------------------\nFeedback\n\n*Do let me know if you have additional feedback or ideas to improve our\nworkflows. Heres an image of how our Chatwoot dashboard looks with recent\nchanges:*\n\n[image: Chatwoot Dashboard Screenshot]\n------------------------------\n\nLooking forward to hearing your thoughts!\n\nBest regards,\n~ Shivam Mishra",
quoted:
'Hi Team,\n\nI hope this email finds you well! I wanted to share some updates regarding\nour integration with *Chatwoot* and outline some key features weve\nexplored.',
},
to: ['shivam@chatwoot.com'],
},
ccEmail: null,
bccEmail: null,
},
attachments: [
getAttachment(
'video',
'https://videos.pexels.com/video-files/1739010/1739010-hd_1920_1080_30fps.mp4'
),
getAttachment(
'image',
'https://images.pexels.com/photos/28506417/pexels-photo-28506417/free-photo-of-motorbike-on-scenic-road-in-surat-thani-thailand.jpeg'
),
getAttachment('file', 'https://chatwoot.dev/invoice.pdf'),
getAttachment('file', 'https://chatwoot.dev/logs.txt'),
getAttachment('file', 'https://chatwoot.dev/contacts.xls'),
getAttachment('file', 'https://chatwoot.dev/customers.csv'),
getAttachment('file', 'https://chatwoot.dev/warehousing-policy.docx'),
getAttachment('file', 'https://chatwoot.dev/pitch-deck.ppt'),
getAttachment('file', 'https://chatwoot.dev/all-files.tar'),
getAttachment(
'audio',
'https://cdn.freesound.org/previews/769/769025_16085454-lq.mp3'
),
],
...baseSenderData.value,
})
);
</script>
<template>
<Story
title="Components/Message Bubbles/Bubbles"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="simpleText" />
</div>
</Variant>
<Variant title="Activity">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="activityMessage" />
</div>
</Variant>
<Variant title="Private Message">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" v-bind="privateText" />
</div>
</Variant>
<!-- Platform Specific -->
<Variant title="Email">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<Message :current-user-id="1" is-email-inbox v-bind="email" />
</div>
</Variant>
</Story>
</template>
@@ -1,45 +0,0 @@
<script setup>
import Message from '../Message.vue';
import textWithMedia from '../fixtures/textWithMedia.js';
const messages = textWithMedia;
const shouldGroupWithNext = index => {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
};
const getReplyToMessage = message => {
const idToCheck = message.contentAttributes.inReplyTo;
if (!idToCheck) return null;
return messages.find(candidate => idToCheck === candidate.id);
};
</script>
<template>
<Story title="Components/Messages/Text" :layout="{ type: 'single' }">
<div class="p-4 bg-n-background rounded-lg w-full min-w-5xl grid">
<template v-for="(message, index) in messages" :key="message.id">
<Message
:current-user-id="1"
:group-with-next="shouldGroupWithNext(index)"
:in-reply-to="getReplyToMessage(message)"
v-bind="message"
/>
</template>
</div>
</Story>
</template>
@@ -794,7 +794,6 @@ watch(conversationFilters, (newVal, oldVal) => {
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -824,7 +823,6 @@ watch(conversationFilters, (newVal, oldVal) => {
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
is-compact
@chat-tab-change="updateAssigneeTab"
/>
@@ -1,13 +1,6 @@
<script setup>
import { computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store.js';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchLayout.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
pageTitle: {
@@ -26,10 +19,6 @@ const props = defineProps({
type: String,
required: true,
},
isOnExpandedLayout: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
@@ -40,13 +29,6 @@ const emit = defineEmits([
'filtersModal',
]);
const { uiSettings, updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const onBasicFilterChange = (value, type) => {
emit('basicFilterChange', value, type);
};
@@ -54,48 +36,26 @@ const onBasicFilterChange = (value, type) => {
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
const showV4View = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CHATWOOT_V4
);
});
const toggleConversationLayout = () => {
const { LAYOUT_TYPES } = wootConstants;
const {
conversation_display_type: conversationDisplayType = LAYOUT_TYPES.CONDENSED,
} = uiSettings.value;
const newViewType =
conversationDisplayType === LAYOUT_TYPES.CONDENSED
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
updateUISettings({
conversation_display_type: newViewType,
previously_used_conversation_display_type: newViewType,
});
};
</script>
<template>
<div
class="flex items-center justify-between gap-2 px-4 pb-0"
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-2.5': showV4View,
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-center min-w-0">
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-lg font-medium truncate text-n-slate-12"
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
@@ -103,82 +63,67 @@ const toggleConversationLayout = () => {
<div class="flex items-center gap-1">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<div class="relative">
<NextButton
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
icon="i-lucide-save"
slate
xs
faded
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emit('addFolders')"
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-40 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
<div id="saveFilterTeleportTarget" class="absolute mt-2 z-40" />
</div>
<NextButton
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
icon="i-lucide-circle-x"
ruby
faded
xs
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emit('resetFilters')"
/>
</template>
<template v-if="hasActiveFolders">
<div class="relative">
<NextButton
<woot-button
id="toggleConversationFilterButton"
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
icon="i-lucide-pen-line"
slate
xs
faded
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emit('filtersModal')"
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
class="absolute mt-2 z-40"
/>
</div>
<NextButton
id="toggleConversationFilterButton"
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
icon="i-lucide-trash-2"
ruby
xs
faded
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emit('deleteFolders')"
/>
</template>
<div v-else class="relative">
<NextButton
<woot-button
id="toggleConversationFilterButton"
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
icon="i-lucide-list-filter"
slate
xs
faded
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emit('filtersModal')"
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
<div id="conversationFilterTeleportTarget" class="absolute mt-2 z-40" />
</div>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<SwitchLayout
v-if="showV4View"
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="toggleConversationLayout"
/>
</div>
</div>
</template>
+12 -6
View File
@@ -75,9 +75,9 @@ onMounted(() => {
@mousedown="handleMouseDown"
>
<div
class="relative max-h-full overflow-auto bg-white shadow-md modal-container rtl:text-right dark:bg-slate-800 skip-context-menu"
class="relative max-h-full overflow-auto shadow-xl modal-container rtl:text-right bg-n-alpha-3 backdrop-blur-[100px] skip-context-menu"
:class="{
'rounded-xl w-[37.5rem]': !fullWidth,
'rounded-xl w-[36rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
@@ -102,38 +102,44 @@ onMounted(() => {
<style lang="scss">
.modal-mask {
@apply flex items-center justify-center bg-modal-backdrop-light dark:bg-modal-backdrop-dark z-[9990] h-full left-0 fixed top-0 w-full;
.modal-container {
&.medium {
@apply max-w-[80%] w-[56.25rem];
}
// .content-box {
// @apply h-auto p-0;
// }
.content {
@apply p-8;
@apply p-6;
}
form,
.modal-content {
@apply pt-4 pb-8 px-8 self-center;
a {
@apply p-4;
}
}
}
}
.modal-big {
@apply w-full;
}
.modal-mask.right-aligned {
@apply justify-end;
.modal-container {
@apply rounded-none h-full w-[30rem];
}
}
.modal-enter,
.modal-leave {
@apply opacity-0;
}
.modal-enter .modal-container,
.modal-leave .modal-container {
transform: scale(1.1);
@@ -23,13 +23,13 @@ export default {
<template>
<div
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
class="ml-0 mr-0 flex py-8 w-full flex-col xl:flex-row"
:class="{
'border-b border-solid border-slate-50 dark:border-slate-700/30':
showBorder,
}"
>
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
<div class="w-full xl:w-1/3 min-w-0 xl:max-w-[40%] pr-12">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
@@ -48,7 +48,7 @@ export default {
{{ note }}
</p>
</div>
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
<div class="w-full xl:w-2/3 min-w-0 xl:max-w-[50%]">
<slot />
</div>
</div>
@@ -8,10 +8,6 @@ const props = defineProps({
type: [Number, String],
required: true,
},
conversationInboxType: {
type: String,
required: true,
},
});
const currentUser = useMapGetter('getCurrentUser');
const messages = ref([]);
@@ -57,7 +53,6 @@ const sendMessage = async message => {
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
@send-message="sendMessage"
/>
</template>
@@ -11,10 +11,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
isCompact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
@@ -63,10 +59,7 @@ onMounted(() => {
<template>
<div
:class="{
'tabs--container--with-border': border,
'tabs--container--compact': isCompact,
}"
:class="{ 'tabs--container--with-border': border }"
class="tabs--container"
>
<button
@@ -89,7 +89,7 @@ export default {
@apply bg-woot-500 dark:bg-woot-500;
}
& + .item {
&+.item {
&::before {
@apply bg-woot-500 dark:bg-woot-500;
}
@@ -201,10 +201,10 @@ export default {
<style lang="scss" scoped>
.filter {
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
@apply p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
&.is-a-macro {
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
@apply mb-0 p-0 border-0 rounded-none;
}
}
@@ -244,6 +244,7 @@ export default {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
@@ -270,9 +271,11 @@ export default {
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
@@ -23,14 +23,12 @@ const goBack = () => {
}
};
const buttonStyleClass = props.compact
? 'text-sm text-slate-600 dark:text-slate-300'
: 'text-base text-woot-500 dark:text-woot-500';
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer"
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
:class="buttonStyleClass"
@click.capture="goBack"
>
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-4 py-0 font-medium [&_a]:text-base [&_.tabs]:p-0"
@change="onTabChange"
>
<woot-tabs-item
@@ -60,13 +60,3 @@ useKeyboardEvents(keyboardEvents);
/>
</woot-tabs>
</template>
<style scoped lang="scss">
.tab--chat-type {
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -14,16 +14,11 @@ export default {
</script>
<template>
<div
class="bg-slate-25 dark:bg-slate-900 pt-4 pb-0 px-8 border-b border-solid border-slate-50 dark:border-slate-800/50"
>
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
<div class="pb-0 px-8 border-b border-n-weak">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
>
<p v-if="headerContent" class="w-full text-n-slate-11 text-sm mb-2">
{{ headerContent }}
</p>
<slot />
@@ -3,7 +3,6 @@ import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import FilterItem from './FilterItem.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import NextButton from 'dashboard/components-next/button/Button.vue';
const CHAT_STATUS_FILTER_ITEMS = Object.freeze([
'open',
@@ -27,13 +26,6 @@ const SORT_ORDER_ITEMS = Object.freeze([
export default {
components: {
FilterItem,
NextButton,
},
props: {
isOnExpandedLayout: {
type: Boolean,
required: true,
},
},
emits: ['changeFilter'],
setup() {
@@ -93,25 +85,22 @@ export default {
<template>
<div class="relative flex">
<NextButton
<woot-button
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
slate
faded
xs
variant="smooth"
size="tiny"
color-scheme="secondary"
class="selector-button"
icon="sort-icon"
@click="toggleDropdown"
/>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="mt-1 dropdown-pane dropdown-pane--open !w-52 !p-4 top-6 border !border-n-weak dark:!border-n-weak !bg-n-alpha-3 dark:!bg-n-alpha-3 backdrop-blur-[100px]"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
}"
class="right-0 mt-1 dropdown-pane dropdown-pane--open basic-filter"
>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
<span class="text-xs font-medium text-slate-800 dark:text-slate-100">{{
$t('CHAT_LIST.CHAT_SORT.STATUS')
}}</span>
<FilterItem
@@ -123,7 +112,7 @@ export default {
/>
</div>
<div class="flex items-center justify-between last:mt-4">
<span class="text-xs font-medium text-n-slate-12">{{
<span class="text-xs font-medium text-slate-800 dark:text-slate-100">{{
$t('CHAT_LIST.CHAT_SORT.ORDER_BY')
}}</span>
<FilterItem
@@ -137,3 +126,9 @@ export default {
</div>
</div>
</template>
<style lang="scss" scoped>
.basic-filter {
@apply w-52 p-4 top-6;
}
</style>
@@ -6,7 +6,7 @@ import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.v
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
defineProps({
currentChat: {
required: true,
type: Object,
@@ -16,13 +16,11 @@ const props = defineProps({
const emit = defineEmits(['toggleContactPanel']);
const getters = useStoreGetters();
const { t } = useI18n();
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
@@ -63,7 +61,7 @@ const showCopilotTab = computed(() => {
@tab-changed="handleTabChange"
/>
</div>
<div class="flex flex-1 overflow-auto">
<div class="overflow-auto flex flex-1">
<ContactPanel
v-if="!activeTab"
:conversation-id="currentChat.id"
@@ -73,7 +71,6 @@ const showCopilotTab = computed(() => {
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
@@ -40,7 +40,7 @@ export default {
<template>
<select
v-model="activeValue"
class="w-32 h-6 py-0 pl-2 pr-6 mx-1 my-0 text-xs border border-solid bg-n-slate-3 dark:bg-n-solid-3 border-n-weak dark:border-n-weak text-n-slate-12"
class="bg-slate-25 dark:bg-slate-700 text-xs h-6 my-0 mx-1 py-0 pr-6 pl-2 w-32 border border-solid border-slate-75 dark:border-slate-600 text-slate-800 dark:text-slate-100"
@change="onTabChange()"
>
<option v-for="value in items" :key="value" :value="value">
@@ -4,13 +4,10 @@ import { ref } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue';
import NextMessage from 'next/message/Message.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
@@ -37,26 +34,9 @@ import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
function shouldGroupWithNext(index, messages) {
if (index === messages.length - 1) return false;
const current = messages[index];
const next = messages[index + 1];
if (next.status === 'failed') return false;
const nextSenderId = next.senderId ?? next.sender?.id;
const currentSenderId = current.senderId ?? current.sender?.id;
if (currentSenderId !== nextSenderId) return false;
// Check if messages are in the same minute by rounding down to nearest minute
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
}
export default {
components: {
Message,
NextMessage,
ReplyBox,
Banner,
ConversationLabelSuggestion,
@@ -76,7 +56,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
const { accountId } = useAccount();
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
@@ -101,10 +80,6 @@ export default {
fetchLabelSuggestions,
} = useAI();
const showNextBubbles = LocalStorage.get(
LOCAL_STORAGE_KEYS.USE_NEXT_BUBBLE
);
return {
isEnterprise,
isPopOutReplyBox,
@@ -114,8 +89,6 @@ export default {
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
accountId,
showNextBubbles,
};
},
data() {
@@ -133,7 +106,6 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
@@ -181,28 +153,16 @@ export default {
return messages;
},
readMessages() {
const readMessages = getReadMessages(
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
if (this.showNextBubbles) {
return useCamelCase(readMessages, { deep: true });
}
return readMessages;
},
unReadMessages() {
const unreadMessages = getUnreadMessages(
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
if (this.showNextBubbles) {
return useCamelCase(unreadMessages, { deep: true });
}
return unreadMessages;
},
shouldShowSpinner() {
return (
@@ -476,19 +436,11 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
getInReplyToMessage(parentMessage) {
// the old implementation took an empty object, but the
// new implementation takes null
const emptyOption = this.showNextBubbles ? null : {};
if (!parentMessage) return emptyOption;
// to maintain backward compatibility we use both the keys
// contentAttributes and content_attributes
// TODO: Remove this once we've migrated all the keys to camelCase
const inReplyToMessageId =
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return emptyOption;
getInReplyToMessage(parentMessage) {
if (!parentMessage) return {};
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return {};
return this.currentChat?.messages.find(message => {
if (message.id === inReplyToMessageId) {
@@ -497,7 +449,6 @@ export default {
return false;
});
},
shouldGroupWithNext: shouldGroupWithNext,
},
};
</script>
@@ -522,46 +473,28 @@ export default {
@click="onToggleContactPanel"
/>
</div>
<ul
class="conversation-panel"
:class="{ 'px-4 bg-n-background': showNextBubbles }"
>
<ul class="conversation-panel">
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]">
<span v-if="shouldShowSpinner" class="spinner message" />
</li>
</transition>
<template v-if="showNextBubbles">
<NextMessage
v-for="(message, index) in readMessages"
:key="message.id"
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, readMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/>
</template>
<template v-else>
<Message
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
</template>
<Message
v-for="message in readMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-an-email-inbox="isAnEmailChannel"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
@@ -572,35 +505,20 @@ export default {
}}
</span>
</li>
<template v-if="showNextBubbles">
<NextMessage
v-for="(message, index) in unReadMessages"
:key="message.id"
v-bind="message"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unReadMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
</template>
<template v-else>
<Message
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
</template>
<Message
v-for="message in unReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
@@ -36,7 +36,7 @@ defineProps({
</div>
<div class="mt-auto">
<p
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
class="text-base text-slate-800 dark:text-slate-100 font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
@@ -32,7 +32,7 @@ const greetingMessage = computed(() => {
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
class="text-xl font-semibold text-slate-900 dark:text-white tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
@@ -459,13 +459,11 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
@@ -1,141 +0,0 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
REPLY_TO_OUTGOING: 'replyToOutgoing',
};
// This is a single source of truth for inbox features
// This is used to check if a feature is available for a particular inbox or not
export const INBOX_FEATURE_MAP = {
[INBOX_FEATURES.REPLY_TO]: [
INBOX_TYPES.FB,
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
],
};
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useInbox = () => {
const currentChat = useMapGetter('getSelectedChat');
const inboxGetter = useMapGetter('inboxes/getInboxById');
const inbox = computed(() => {
const inboxId = currentChat.value.inbox_id;
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
});
const channelType = computed(() => {
return inbox.value.channelType;
});
const isAPIInbox = computed(() => {
return channelType.value === INBOX_TYPES.API;
});
const isAFacebookInbox = computed(() => {
return channelType.value === INBOX_TYPES.FB;
});
const isAWebWidgetInbox = computed(() => {
return channelType.value === INBOX_TYPES.WEB;
});
const isATwilioChannel = computed(() => {
return channelType.value === INBOX_TYPES.TWILIO;
});
const isALineChannel = computed(() => {
return channelType.value === INBOX_TYPES.LINE;
});
const isAnEmailChannel = computed(() => {
return channelType.value === INBOX_TYPES.EMAIL;
});
const isATelegramChannel = computed(() => {
return channelType.value === INBOX_TYPES.TELEGRAM;
});
const whatsAppAPIProvider = computed(() => {
return inbox.value.provider || '';
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
});
const isAGoogleInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'google';
});
const isATwilioSMSChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
return isATwilioChannel.value && medium === 'sms';
});
const isASmsInbox = computed(() => {
return channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value;
});
const isATwilioWhatsAppChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
return isATwilioChannel.value && medium === 'whatsapp';
});
const isAWhatsAppCloudChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'whatsapp_cloud'
);
});
const is360DialogWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'default'
);
});
const isAWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP ||
isATwilioWhatsAppChannel.value
);
});
return {
inbox,
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAMicrosoftInbox,
isAGoogleInbox,
isATwilioWhatsAppChannel,
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
};
};
@@ -3,48 +3,23 @@
import { unref } from 'vue';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import * as Sentry from '@sentry/vue';
/**
* Vue composable that converts object keys to camelCase
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
* @param {Object} [options] - Options object
* @param {boolean} [options.deep=false] - Should convert keys of nested objects
* @returns {Object|Array} Converted payload with camelCase keys
*/
export function useCamelCase(payload, options) {
try {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'camelCase',
});
Sentry.captureException(e);
return payload;
}
export function useCamelCase(payload) {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload);
}
/**
* Vue composable that converts object keys to snake_case
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
* @param {Object} [options] - Options object
* @param {boolean} [options.deep=false] - Should convert keys of nested objects
* @returns {Object|Array} Converted payload with snake_case keys
*/
export function useSnakeCase(payload, options) {
try {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'snakeCase',
});
Sentry.captureException(e);
return payload;
}
export function useSnakeCase(payload) {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload);
}
@@ -5,5 +5,4 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
USE_NEXT_BUBBLE: 'useNextBubble',
};
@@ -6,7 +6,6 @@
"LIST": {
"404": "There are no active conversations in this group."
},
"FAILED_TO_SEND": "Failed to send",
"TAB_HEADING": "Conversations",
"MENTION_HEADING": "Mentions",
"UNATTENDED_HEADING": "Unattended",
@@ -94,9 +93,6 @@
"location": {
"CONTENT": "Location"
},
"ig_reel": {
"CONTENT": "Instagram Reel"
},
"fallback": {
"CONTENT": "has shared a url"
}
@@ -130,7 +126,6 @@
"NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"MESSAGE_READ": "Read"
}
}
@@ -38,16 +38,10 @@
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save Contact",
"SHARED_ATTACHMENT": {
"CONTACT": "{sender} has shared a contact",
"LOCATION": "{sender} has shared a location",
"FILE": "{sender} has shared a file",
"MEETING": "{sender} has started a meeting"
},
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
"UNSUPPORTED_MESSAGE": "This message is unsupported.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
@@ -320,8 +314,7 @@
"TO": "To",
"BCC": "Bcc",
"CC": "Cc",
"SUBJECT": "Subject",
"EXPAND": "Expand email"
"SUBJECT": "Subject"
},
"CONVERSATION_PARTICIPANTS": {
"SIDEBAR_MENU_TITLE": "Participating",
@@ -305,8 +305,7 @@
"COPILOT": {
"SEND_MESSAGE": "Send message...",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this"
"YOU": "You"
}
}
}
@@ -236,10 +236,6 @@
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
},
"MEDIA": {
"IMAGE_UNAVAILABLE": "This image is no longer available.",
"LOADING_FAILED": "Loading failed"
}
},
"CONFIRM_EMAIL": "Verifying...",
@@ -4,7 +4,6 @@ import { mapGetters } from 'vuex';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
@@ -39,10 +38,6 @@ export default {
type: Object,
default: () => ({}),
},
hideButton: {
type: Boolean,
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
setup() {
@@ -67,7 +62,7 @@ export default {
return this.getPlainText(this.messageContent);
},
conversationId() {
return this.message.conversation_id ?? this.message.conversationId;
return this.message.conversation_id;
},
messageId() {
return this.message.id;
@@ -76,9 +71,7 @@ export default {
return this.message.content;
},
contentAttributes() {
return useSnakeCase(
this.message.content_attributes ?? this.message.contentAttributes
);
return this.message.content_attributes;
},
},
methods: {
@@ -190,7 +183,6 @@ export default {
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
/>
<woot-button
v-if="!hideButton"
icon="more-vertical"
color-scheme="secondary"
variant="clear"
@@ -1,7 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import PopOverSearch from './search/PopOverSearch.vue';
@@ -9,7 +8,6 @@ import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
@@ -54,12 +52,10 @@ export default {
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
return {
uiSettings,
updateUISettings,
accountId,
};
},
data() {
@@ -71,7 +67,6 @@ export default {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
@@ -95,12 +90,6 @@ export default {
}
return false;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHATWOOT_V4
);
},
},
watch: {
conversationId() {
@@ -216,7 +205,6 @@ export default {
@conversation-load="onConversationLoad"
>
<PopOverSearch
v-if="showPopOverSearch"
:is-on-expanded-layout="isOnExpandedLayout"
@toggle-conversation-layout="toggleConversationLayout"
/>
@@ -34,25 +34,25 @@ export default {
<template>
<div class="relative">
<div
class="flex px-4 pb-1 justify-between items-center flex-row gap-1 pt-2.5 border-b border-transparent"
class="flex px-4 pb-1 flex-row gap-1 pt-2.5 border-b border-transparent"
>
<woot-sidemenu-icon
size="tiny"
class="relative top-0 ltr:-ml-1.5 rtl:-mr-1.5 flex-shrink-0 focus:!bg-n-solid-3 dark:!hover:bg-n-solid-2 hover:!bg-n-alpha-2"
class="relative top-0 ltr:-ml-1.5 rtl:-mr-1.5"
/>
<router-link
:to="searchUrl"
class="inline-flex items-center flex-1 h-6 min-w-0 gap-1 px-2 py-0 text-left rounded-md rtl:mr-2.5 search-link rtl:text-right bg-n-slate-9/10 hover:bg-n-slate-3"
class="inline-flex items-center flex-1 h-6 gap-1 px-2 py-0 text-left rounded-md search-link rtl:mr-3 rtl:text-right bg-slate-25 dark:bg-slate-800"
>
<div class="flex flex-shrink-0">
<div class="flex">
<fluent-icon
icon="search"
class="search--icon text-n-slate-11"
class="search--icon text-slate-800 dark:text-slate-200"
size="16"
/>
</div>
<p
class="mb-0 overflow-hidden text-sm search--label whitespace-nowrap text-ellipsis text-n-slate-11"
class="mb-0 overflow-hidden text-sm search--label whitespace-nowrap text-ellipsis text-slate-800 dark:text-slate-200"
>
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</p>
@@ -1,10 +1,5 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -21,17 +16,22 @@ export default {
</script>
<template>
<NextButton
<woot-button
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
:icon="
isOnExpandedLayout
? 'i-lucide-arrow-left-to-line'
: 'i-lucide-arrow-right-to-line'
"
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0"
icon="arrow-right-import"
size="tiny"
variant="smooth"
color-scheme="secondary"
class="layout-switch__container"
:class="{ expanded: isOnExpandedLayout }"
@click="toggle"
/>
</template>
<style lang="scss" soped>
.layout-switch__container {
&.expanded .icon {
transform: rotate(180deg);
}
}
</style>
@@ -54,11 +54,9 @@ export default {
<template>
<div
class="flex justify-between items-center h-14 min-h-[3.5rem] px-4 py-2 bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800/50"
class="flex justify-between items-center h-20 min-h-[3.5rem] py-2 max-w-[960px] mx-auto w-full"
>
<h1
class="flex items-center mb-0 text-2xl text-slate-900 dark:text-slate-100"
>
<h1 class="flex items-center mb-0 text-xl font-medium text-n-slate-12">
<woot-sidemenu-icon v-if="showSidemenuIcon" />
<BackButton
v-if="showBackButton"
@@ -66,23 +64,20 @@ export default {
:back-url="backUrl"
class="ml-2 mr-4"
/>
<fluent-icon
v-if="icon"
:icon="icon"
:class="iconClass"
class="hidden ml-1 mr-2 rtl:ml-2 rtl:mr-1 md:block"
/>
<slot />
<span class="text-2xl font-medium text-slate-900 dark:text-slate-100">
<span
class="text-xl font-medium text-n-slate-12 font-interDisplay tracking-[0.3px]"
>
{{ headerTitle }}
</span>
</h1>
<slot name="action-button" />
<router-link
v-if="showNewButton && isAdmin"
:to="buttonRoute"
class="button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
class="button success px-3.5 py-1 rounded-[5px] flex gap-2"
>
<fluent-icon icon="add-circle" />
<fluent-icon icon="add" />
<span class="button__content">
{{ buttonText }}
</span>
@@ -20,10 +20,10 @@ defineProps({
</script>
<template>
<div class="flex flex-col w-full h-full gap-8 font-inter">
<div class="flex flex-col w-full h-full gap-8">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<div>
<div class="mx-auto max-w-[960px] w-full pb-10">
<slot name="preBody" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
@@ -8,9 +8,7 @@ defineProps({
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-white dark:bg-slate-900 font-inter"
>
<div class="flex flex-col w-full h-full m-0 overflow-auto bg-n-background">
<div class="flex items-start w-full max-w-6xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
@@ -22,9 +22,7 @@ const showNewButton = computed(
</script>
<template>
<div
class="flex flex-1 h-full justify-between flex-col m-0 bg-slate-25 dark:bg-slate-900 overflow-auto"
>
<div class="w-full h-full bg-n-background overflow-auto">
<SettingsHeader
button-route="new"
:icon="icon"
@@ -161,100 +161,104 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink min-w-0 p-6 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row p-4 border-b border-slate-25 dark:border-slate-800"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
<div
class="flex-grow flex-shrink min-w-0 p-6 overflow-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5 max-w-[960px] w-full mx-auto mb-10"
>
<div v-if="!uiFlags.isFetchingItem">
<form @submit.prevent="updateAccount">
<div class="flex flex-row p-4 border-n-weak border-b">
<div
class="flex-grow-0 flex-shrink-0 flex-[30%] min-w-0 py-4 pr-6 pl-0"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</div>
</div>
<h4 class="text-lg font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
<div
class="flex flex-row p-4 border-slate-25 dark:border-slate-700 text-black-900 dark:text-slate-300"
>
<woot-submit-button
class="button nice"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</div>
</div>
</form>
<div class="flex flex-row p-4 text-n-slate-12">
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
class="flex-grow-0 flex-shrink-0 flex-[30%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
<h4 class="text-lg font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
@@ -265,7 +269,7 @@ export default {
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div class="p-4 text-sm text-center text-n-slate-11">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
@@ -278,14 +282,7 @@ export default {
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
<woot-submit-button
class="button nice success button--fixed-top"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</form>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<woot-loading-state v-else />
</div>
</template>
@@ -45,7 +45,9 @@ export default {
</script>
<template>
<div class="flex-1 p-4 overflow-auto">
<div
class="flex-1 p-4 max-w-[960px] w-full mx-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5"
>
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<woot-loading-state
@@ -76,7 +78,7 @@ export default {
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
icon="add"
>
<router-link :to="newAgentBotsURL" class="white-text">
{{ $t('AGENT_BOTS.ADD.TITLE') }}
@@ -46,11 +46,16 @@ export default {
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<div
class="max-w-[960px] mx-auto w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 mb-10"
>
<div class="flex flex-row">
<div class="w-[68%]">
<div class="h-[calc(100vh-56px)] relative">
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
<CsmlMonacoEditor
v-model="bot.csmlContent"
class="w-full h-full rounded-tl-xl"
/>
<div
v-if="v$.bot.csmlContent.$error"
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
@@ -2,6 +2,7 @@
import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import {
useStoreGetters,
@@ -149,20 +150,19 @@ const confirmDeletion = () => {
feature-name="agents"
>
<template #actions>
<woot-button
class="rounded-md button nice"
icon="add-circle"
<Button
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('AGENT_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<table class="divide-y divide-n-weak">
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
@@ -158,130 +158,126 @@ export default {
</script>
<template>
<woot-modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('ATTRIBUTES_MGMT.ADD.TITLE')" />
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('ATTRIBUTES_MGMT.ADD.TITLE')" />
<form class="flex w-full" @submit.prevent="addAttributes">
<div class="w-full">
<label :class="{ error: v$.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="v$.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
<form class="flex w-full" @submit.prevent="addAttributes">
<div class="w-full">
<label :class="{ error: v$.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="v$.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
</label>
<woot-input
v-model="displayName"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: v$.displayName.$error }"
:error="
v$.displayName.$error
? $t('ATTRIBUTES_MGMT.ADD.FORM.NAME.ERROR')
: ''
"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@update:model-value="onDisplayNameChange"
@blur="v$.displayName.$touch"
/>
<woot-input
v-model="attributeKey"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
type="text"
:class="{ error: v$.attributeKey.$error }"
:error="v$.attributeKey.$error ? keyErrorMessage : ''"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
@blur="v$.attributeKey.$touch"
/>
<label :class="{ error: v$.description.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.LABEL') }}
<textarea
v-model="description"
rows="3"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.DESC.PLACEHOLDER')"
@blur="v$.description.$touch"
/>
<span v-if="v$.description.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.ERROR') }}
</span>
</label>
<label :class="{ error: v$.attributeType.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LABEL') }}
<select v-model="attributeType">
<option v-for="type in types" :key="type.id" :value="type.id">
{{ type.option }}
</option>
</select>
<span v-if="v$.attributeType.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label>
<woot-input
v-model="displayName"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: v$.displayName.$error }"
:error="
v$.displayName.$error
? $t('ATTRIBUTES_MGMT.ADD.FORM.NAME.ERROR')
: ''
"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@update:model-value="onDisplayNameChange"
@blur="v$.displayName.$touch"
<multiselect
ref="tagInput"
v-model="values"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@close="onTouch"
@tag="addTagValue"
/>
<woot-input
v-model="attributeKey"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
type="text"
:class="{ error: v$.attributeKey.$error }"
:error="v$.attributeKey.$error ? keyErrorMessage : ''"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
@blur="v$.attributeKey.$touch"
/>
<label :class="{ error: v$.description.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.LABEL') }}
<textarea
v-model="description"
rows="3"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.DESC.PLACEHOLDER')"
@blur="v$.description.$touch"
/>
<span v-if="v$.description.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.ERROR') }}
</span>
<label v-show="isMultiselectInvalid" class="error-message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
<label :class="{ error: v$.attributeType.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LABEL') }}
<select v-model="attributeType">
<option v-for="type in types" :key="type.id" :value="type.id">
{{ type.option }}
</option>
</select>
<span v-if="v$.attributeType.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label>
<multiselect
ref="tagInput"
v-model="values"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@close="onTouch"
@tag="addTagValue"
/>
<label v-show="isMultiselectInvalid" class="error-message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
</div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-submit-button
:disabled="isButtonDisabled"
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</woot-modal>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-submit-button
:disabled="isButtonDisabled"
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
@@ -1,6 +1,8 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
@@ -68,13 +70,12 @@ const onClickTabChange = index => {
feature-name="custom_attributes"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
@@ -83,7 +83,9 @@ const tableHeaders = computed(() => {
feature-name="audit_logs"
/>
<div class="mt-6 flex-1 text-slate-700 dark:text-slate-300">
<div
class="mt-6 flex-1 w-full max-w-[960px] mx-auto text-slate-700 dark:text-slate-300"
>
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('AUDIT_LOGS.LOADING')"
@@ -95,7 +97,7 @@ const tableHeaders = computed(() => {
{{ $t('AUDIT_LOGS.LIST.404') }}
</p>
<div v-else class="min-w-full overflow-x-auto">
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<table class="divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -106,7 +108,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 pr-4 break-all whitespace-nowrap">
@@ -3,6 +3,8 @@ import { useAlert } from 'dashboard/composables';
import AddAutomationRule from './AddAutomationRule.vue';
import EditAutomationRule from './EditAutomationRule.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -185,18 +187,17 @@ const tableHeaders = computed(() => {
feature-name="automation"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('AUTOMATION.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('AUTOMATION.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -207,7 +208,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<AutomationRuleRow
v-for="automation in records"
@@ -58,23 +58,24 @@ export default {
</script>
<template>
<div class="flex-1 p-6 overflow-auto dark:bg-slate-900">
<div class="flex-1 py-6 overflow-auto mx-auto max-w-[960px] w-full p-1">
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div v-else-if="!hasABillingPlan">
<p>{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}</p>
</div>
<div v-else class="w-full">
<div class="current-plan--details">
<h6>{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}</h6>
<div v-else class="w-full flex flex-col gap-5">
<div class="border-b border-n-weak pb-4">
<h6 class="text-n-slate-12">
{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}
</h6>
<div
v-dompurify-html="
formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
v-dompurify-html="formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
/>
</div>
<BillingItem
@@ -93,31 +94,3 @@ export default {
</div>
</div>
</template>
<style lang="scss">
.manage-subscription {
@apply bg-white dark:bg-slate-800 flex justify-between mb-2 py-6 px-4 items-center rounded-md border border-solid border-slate-75 dark:border-slate-700;
}
.current-plan--details {
@apply border-b border-solid border-slate-75 dark:border-slate-800 mb-4 pb-4;
h6 {
@apply text-slate-800 dark:text-slate-100;
}
p {
@apply text-slate-600 dark:text-slate-200;
}
}
.manage-subscription {
.manage-subscription--description {
@apply mb-0 text-slate-600 dark:text-slate-200;
}
h6 {
@apply text-slate-800 dark:text-slate-100;
}
}
</style>
@@ -1,40 +1,44 @@
<script>
export default {
props: {
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: '',
},
emits: ['open'],
};
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
});
defineEmits(['open']);
</script>
<template>
<div class="manage-subscription">
<div
class="flex justify-between items-center shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5"
>
<div>
<h6>{{ title }}</h6>
<p class="manage-subscription--description">
<h6 class="text-n-slate-12">{{ title }}</h6>
<p class="mb-0 text-n-slate-11">
{{ description }}
</p>
</div>
<div>
<woot-button variant="smooth" :icon="buttonIcon" @click="$emit('open')">
{{ buttonLabel }}
</woot-button>
<Button
:label="buttonLabel"
variant="faded"
size="sm"
@click="$emit('open')"
/>
</div>
</div>
</template>
@@ -4,14 +4,12 @@ import { required, minLength } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton.vue';
import Modal from '../../../../components/Modal.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
name: 'AddCanned',
components: {
WootSubmitButton,
Modal,
WootMessageEditor,
},
props: {
@@ -82,58 +80,56 @@ export default {
</script>
<template>
<Modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
:header-content="$t('CANNED_MGMT.ADD.DESC')"
/>
<form class="flex flex-col w-full" @submit.prevent="addCannedResponse()">
<div class="w-full">
<label :class="{ error: v$.shortCode.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
<input
v-model="shortCode"
type="text"
:placeholder="$t('CANNED_MGMT.ADD.FORM.SHORT_CODE.PLACEHOLDER')"
@blur="v$.shortCode.$touch"
/>
</label>
</div>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
:header-content="$t('CANNED_MGMT.ADD.DESC')"
/>
<form class="flex flex-col w-full" @submit.prevent="addCannedResponse()">
<div class="w-full">
<label :class="{ error: v$.shortCode.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
<input
v-model="shortCode"
type="text"
:placeholder="$t('CANNED_MGMT.ADD.FORM.SHORT_CODE.PLACEHOLDER')"
@blur="v$.shortCode.$touch"
/>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.content.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.CONTENT.LABEL') }}
</label>
<div class="editor-wrap">
<WootMessageEditor
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@blur="v$.content.$touch"
/>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<WootSubmitButton
:disabled="
v$.content.$invalid ||
v$.shortCode.$invalid ||
addCanned.showLoading
"
:button-text="$t('CANNED_MGMT.ADD.FORM.SUBMIT')"
:loading="addCanned.showLoading"
<div class="w-full">
<label :class="{ error: v$.content.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.CONTENT.LABEL') }}
</label>
<div class="editor-wrap">
<WootMessageEditor
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@blur="v$.content.$touch"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('CANNED_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</Modal>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<WootSubmitButton
:disabled="
v$.content.$invalid ||
v$.shortCode.$invalid ||
addCanned.showLoading
"
:button-text="$t('CANNED_MGMT.ADD.FORM.SUBMIT')"
:loading="addCanned.showLoading"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('CANNED_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</template>
<style scoped lang="scss">
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
@@ -124,17 +125,15 @@ const tableHeaders = computed(() => {
feature-name="canned_responses"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
<div class="mt-6 flex-1">
<div class="mt-6 flex-1 w-full max-w-[960px] mx-auto">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
@@ -145,10 +144,7 @@ const tableHeaders = computed(() => {
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -174,7 +170,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr
v-for="(cannedItem, index) in records"
@@ -213,7 +209,6 @@ const tableHeaders = computed(() => {
</tbody>
</table>
</div>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
@@ -38,31 +38,14 @@ const openInNewTab = url => {
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 pt-4">
<BackButton
v-if="backButtonLabel"
compact
:button-label="backButtonLabel"
/>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3">
<div
v-if="iconName"
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
>
<div
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
>
<fluent-icon
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
/>
</div>
</div>
<div class="flex flex-col items-start w-full gap-2 max-w-[960px] mx-auto">
<div
class="flex items-center justify-between w-full gap-4 h-20 border-b border-n-weak"
>
<div class="flex gap-3">
<BackButton v-if="backButtonLabel" compact />
<h1
class="text-2xl font-semibold font-interDisplay tracking-[0.3px] text-slate-900 dark:text-slate-25"
class="text-xl font-interDisplay tracking-[0.3px] font-medium text-n-slate-12"
>
{{ title }}
</h1>
@@ -72,9 +55,9 @@ const openInNewTab = url => {
<slot name="actions" />
</div>
</div>
<div class="flex flex-col gap-3 text-slate-600 dark:text-slate-300 w-full">
<div class="flex flex-col gap-4 text-n-slate-11 w-full pt-3">
<p
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl tracking-[-0.1px]"
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl leading-6"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -84,14 +67,14 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="sm:inline-flex hidden gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium hover:underline"
class="sm:inline-flex hidden gap-1 w-fit items-center text-n-brand text-sm font-medium hover:underline"
>
{{ linkText }}
<fluent-icon
size="16"
icon="chevron-right"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
class="flex-shrink-0 text-n-brand"
/>
</a>
</CustomBrandPolicyWrapper>
@@ -105,7 +88,7 @@ const openInNewTab = url => {
v-if="helpURL && linkText"
color-scheme="secondary"
icon="arrow-outwards"
class="flex-row-reverse rounded-md min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
class="flex-row-reverse rounded-md text-sm min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
@click="openInNewTab(helpURL)"
>
{{ linkText }}
@@ -13,7 +13,7 @@ defineProps({
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 w-full"
class="flex relative flex-col sm:flex-row px-4 py-4 gap-4 sm:px-6 sm:py-5 justify-between shadow group bg-n-solid-2 rounded-xl outline-1 outline outline-n-container w-full"
>
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
@@ -8,6 +8,7 @@ import CustomRolePaywall from './component/CustomRolePaywall.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore();
const { t } = useI18n();
@@ -134,14 +135,13 @@ const confirmDeletion = () => {
feature-name="canned_responses"
>
<template #actions>
<woot-button
class="rounded-md button nice"
icon="add-circle"
<Button
:label="$t('CUSTOM_ROLE.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
:disabled="isBehindAPaywall"
@click="openAddModal"
>
{{ $t('CUSTOM_ROLE.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
@@ -150,7 +150,7 @@ const confirmDeletion = () => {
<CustomRolePaywall v-if="isBehindAPaywall" />
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
class="min-w-full overflow-x-auto divide-y divide-n-weak"
>
<thead>
<th
@@ -26,7 +26,7 @@ const getFormattedPermissions = role => {
<template>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="(customRole, index) in roles" :key="index">
<td

Some files were not shown because too many files have changed in this diff Show More