Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { content, createdAt } = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
v-tooltip.top="readableTime"
|
||||
class="px-3 py-1 !rounded-xl flex min-w-0 items-center gap-2"
|
||||
data-bubble-name="activity"
|
||||
>
|
||||
<span v-dompurify-html="content" :title="content" />
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<AudioChip
|
||||
:attachment="attachment"
|
||||
class="p-2 text-n-slate-12 skip-context-menu"
|
||||
/>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageMeta from '../MessageMeta.vue';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
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]: '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]:
|
||||
'left-bubble rounded-xl ltr:rounded-bl-sm rtl:rounded-br-sm',
|
||||
[ORIENTATION.RIGHT]:
|
||||
'right-bubble rounded-xl ltr:rounded-br-sm rtl:rounded-bl-sm',
|
||||
[ORIENTATION.CENTER]: 'rounded-md',
|
||||
};
|
||||
|
||||
const flexOrientationClass = computed(() => {
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'justify-start',
|
||||
[ORIENTATION.RIGHT]: 'justify-end',
|
||||
[ORIENTATION.CENTER]: 'justify-center',
|
||||
};
|
||||
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
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: inReplyTo.value.id,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMeta = computed(
|
||||
() =>
|
||||
!props.hideMeta &&
|
||||
!shouldGroupWithNext.value &&
|
||||
variant.value !== MESSAGE_VARIANTS.ACTIVITY
|
||||
);
|
||||
|
||||
const replyToPreview = computed(() => {
|
||||
if (!inReplyTo) return '';
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return new MessageFormatter(content).formattedMessage;
|
||||
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"
|
||||
:class="[
|
||||
messageClass,
|
||||
{
|
||||
'max-w-lg': variant !== MESSAGE_VARIANTS.EMAIL,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="inReplyTo"
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="replyToPreview"
|
||||
class="prose prose-bubble line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
defineProps({
|
||||
icon: { type: [String, Object], required: true },
|
||||
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
|
||||
senderTranslationKey: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
title: { type: String, default: '' }, // Title can be any name, description, etc
|
||||
action: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: action => {
|
||||
return action.label && (action.href || action.onClick);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sender } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(() => {
|
||||
return sender?.value?.name || '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
|
||||
<div class="grid gap-4 min-w-64">
|
||||
<div class="grid gap-3">
|
||||
<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 overflow-hidden">
|
||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||
{{
|
||||
t(senderTranslationKey, {
|
||||
sender: senderName,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<slot>
|
||||
<div v-if="title" class="truncate text-sm text-n-slate-12">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div v-if="content" class="truncate text-sm text-n-slate-11">
|
||||
{{ content }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action" class="mb-2">
|
||||
<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 border border-n-container"
|
||||
>
|
||||
{{ action.label }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="w-full bg-n-solid-3 px-4 py-2 rounded-lg text-sm text-center border border-n-container"
|
||||
@click="action.onClick"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { contentAttributes, content } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const response = computed(() => {
|
||||
return contentAttributes.value?.submittedValues?.csatSurveyResponse ?? {};
|
||||
});
|
||||
|
||||
const isRatingSubmitted = computed(() => {
|
||||
return !!response.value.rating;
|
||||
});
|
||||
|
||||
const displayType = computed(() => {
|
||||
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
|
||||
});
|
||||
|
||||
const isStarRating = computed(() => {
|
||||
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (isRatingSubmitted.value) {
|
||||
return CSAT_RATINGS.find(
|
||||
csatOption => csatOption.value === response.value.rating
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const starRatingValue = computed(() => {
|
||||
return response.value.rating || 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<dl v-if="isRatingSubmitted" class="mt-4">
|
||||
<dt class="text-n-slate-11 italic">
|
||||
{{ t('CONVERSATION.RATING_TITLE') }}
|
||||
</dt>
|
||||
<dd v-if="!isStarRating">
|
||||
{{ t(rating.translationKey) }}
|
||||
</dd>
|
||||
<dd v-else class="flex mt-1">
|
||||
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
|
||||
<i
|
||||
:class="[
|
||||
n <= starRatingValue
|
||||
? 'i-ri-star-fill text-n-amber-9'
|
||||
: 'i-ri-star-line text-n-slate-10',
|
||||
]"
|
||||
/>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
|
||||
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
|
||||
</dt>
|
||||
<dd>{{ response.feedbackMessage }}</dd>
|
||||
</dl>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
|
||||
const $store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const phoneNumber = computed(() => {
|
||||
return attachment.value.fallbackTitle;
|
||||
});
|
||||
|
||||
const contactName = computed(() => {
|
||||
const { meta } = attachment.value ?? {};
|
||||
const { firstName, lastName } = meta ?? {};
|
||||
return `${firstName ?? ''} ${lastName ?? ''}`.trim();
|
||||
});
|
||||
|
||||
const formattedPhoneNumber = computed(() => {
|
||||
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
|
||||
});
|
||||
|
||||
const rawPhoneNumber = computed(() => {
|
||||
return phoneNumber.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
function getContactObject() {
|
||||
const contactItem = {
|
||||
name: contactName.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-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
|
||||
:title="contactName"
|
||||
:content="phoneNumber"
|
||||
:action="formattedPhoneNumber ? action : null"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import DyteAPI from 'dashboard/api/integrations/dyte';
|
||||
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
|
||||
const { content, sender, id } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const dyteAuthToken = ref('');
|
||||
|
||||
const meetingLink = computed(() => {
|
||||
return buildDyteURL(dyteAuthToken.value);
|
||||
});
|
||||
|
||||
const joinTheCall = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
|
||||
id.value
|
||||
);
|
||||
dyteAuthToken.value = token;
|
||||
} catch (err) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const leaveTheRoom = () => {
|
||||
dyteAuthToken.value = '';
|
||||
};
|
||||
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-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
|
||||
:action="action"
|
||||
>
|
||||
<div v-if="!sender" class="text-sm truncate text-n-slate-12">
|
||||
<!-- Added as a fallback, where the sender is not available (Deleted) -->
|
||||
<!-- Will show the content, if senderName in BaseAttachment.vue is empty -->
|
||||
{{ content }}
|
||||
</div>
|
||||
<div v-if="dyteAuthToken" class="video-call--container">
|
||||
<iframe
|
||||
:src="meetingLink"
|
||||
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 text-sm rounded-lg bg-n-solid-3 mt-3"
|
||||
@click="leaveTheRoom"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ '' }}
|
||||
</div>
|
||||
</BaseAttachmentBubble>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.join-call-button {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.video-call--container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
padding: 0.25rem;
|
||||
@apply bg-n-background;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 10rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_STATUS } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
|
||||
const { contentAttributes, status, sender } = useMessageContext();
|
||||
|
||||
const hasError = computed(() => {
|
||||
return status.value === MESSAGE_STATUS.FAILED;
|
||||
});
|
||||
|
||||
const fromEmail = computed(() => {
|
||||
return contentAttributes.value?.email?.from ?? [];
|
||||
});
|
||||
|
||||
const toEmail = computed(() => {
|
||||
const { toEmails, email } = contentAttributes.value;
|
||||
return email?.to ?? toEmails ?? [];
|
||||
});
|
||||
|
||||
const ccEmail = computed(() => {
|
||||
return (
|
||||
contentAttributes.value?.ccEmails ??
|
||||
contentAttributes.value?.email?.cc ??
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const senderName = computed(() => {
|
||||
const fromEmailAddress = fromEmail.value[0] ?? '';
|
||||
const senderEmail = sender.value.email ?? '';
|
||||
|
||||
if (!fromEmailAddress && !senderEmail) return null;
|
||||
|
||||
// if the sender of the conversation and the sender of this particular
|
||||
// email are the same, only then we return the sender name
|
||||
if (fromEmailAddress === senderEmail) {
|
||||
return sender.value.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const bccEmail = computed(() => {
|
||||
return (
|
||||
contentAttributes.value?.bccEmails ??
|
||||
contentAttributes.value?.email?.bcc ??
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
const subject = computed(() => {
|
||||
return contentAttributes.value?.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="space-y-1 rtl:pl-9 ltr:pr-9 text-sm break-words"
|
||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
<template v-if="showMeta">
|
||||
<div
|
||||
v-if="fromEmail[0]"
|
||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
|
||||
>
|
||||
<template v-if="senderName">
|
||||
<span>
|
||||
{{ senderName }}
|
||||
</span>
|
||||
<{{ fromEmail[0] }}>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ fromEmail[0] }}
|
||||
</template>
|
||||
</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>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, onMounted } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { sanitizeTextForRender } from '@chatwoot/utils';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const renderOriginal = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||
});
|
||||
|
||||
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isIncoming = computed(() => !isOutgoing.value);
|
||||
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const originalEmailText = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return sanitizeTextForRender(text);
|
||||
});
|
||||
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ||
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const hasEmailContent = computed(() => {
|
||||
return (
|
||||
contentAttributes?.value?.email?.textContent?.full ||
|
||||
contentAttributes?.value?.email?.htmlContent?.full
|
||||
);
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original content
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const textToShow = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original text
|
||||
return originalEmailText.value;
|
||||
});
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original HTML
|
||||
return originalEmailHtml.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||
// This forces Vue to re-render the component and update content correctly.
|
||||
const translationKeySuffix = computed(() => {
|
||||
if (renderOriginal.value) return 'original';
|
||||
if (hasTranslations.value) return 'translated';
|
||||
return 'original';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="w-full"
|
||||
:class="{
|
||||
'bg-n-slate-4': isIncoming,
|
||||
'bg-n-solid-blue': isOutgoing,
|
||||
}"
|
||||
data-bubble-name="email"
|
||||
>
|
||||
<EmailMeta
|
||||
class="p-3"
|
||||
:class="{
|
||||
'border-b border-n-strong': isIncoming,
|
||||
'border-b border-n-slate-8/20': isOutgoing,
|
||||
}"
|
||||
/>
|
||||
<section ref="contentContainer" class="p-3">
|
||||
<div
|
||||
:class="{
|
||||
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
|
||||
'overflow-y-scroll relative': isExpanded,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isExpandable && !isExpanded"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
|
||||
:class="{
|
||||
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
|
||||
isIncoming,
|
||||
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
|
||||
isOutgoing,
|
||||
}"
|
||||
>
|
||||
<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 && !hasEmailContent"
|
||||
class="text-n-slate-12"
|
||||
:content="messageContent"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
:key="`letter-quoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
:html="fullHTML"
|
||||
:text="textToShow"
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
:key="`letter-unquoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
: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>
|
||||
</div>
|
||||
</section>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="py-2 px-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<section
|
||||
v-if="Array.isArray(attachments) && attachments.length"
|
||||
class="px-4 pb-4 space-y-2"
|
||||
>
|
||||
<AttachmentChips :attachments="attachments" class="gap-1" />
|
||||
</section>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
// Tailwind resets break the rendering of google drive link in Gmail messages
|
||||
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
|
||||
|
||||
.letter-render [class*='gmail_drive_chip'] {
|
||||
box-sizing: initial;
|
||||
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
|
||||
|
||||
a {
|
||||
@apply text-n-slate-12 !important;
|
||||
|
||||
img {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
|
||||
<div
|
||||
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
|
||||
>
|
||||
<iframe
|
||||
class="w-full h-full border-0 rounded-lg"
|
||||
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
|
||||
:src="attachment.dataUrl"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
import FileIcon from 'next/icon/FileIcon.vue';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const url = computed(() => {
|
||||
return attachments.value[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-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>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CONTENT_TYPES } from '../constants.js';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
|
||||
const { content, contentAttributes, contentType } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const { isAWebWidgetInbox } = useInbox();
|
||||
|
||||
const formValues = computed(() => {
|
||||
if (contentType.value === CONTENT_TYPES.FORM) {
|
||||
const { items, submittedValues = [] } = contentAttributes.value;
|
||||
|
||||
if (submittedValues.length) {
|
||||
return submittedValues.map(submittedValue => {
|
||||
const item = items.find(
|
||||
formItem => formItem.name === submittedValue.name
|
||||
);
|
||||
return {
|
||||
title: submittedValue.value,
|
||||
value: submittedValue.value,
|
||||
label: item?.label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (contentType.value === CONTENT_TYPES.INPUT_SELECT) {
|
||||
const [item] = contentAttributes.value?.submittedValues ?? [];
|
||||
if (!item) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
label: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<span v-dompurify-html="content" :title="content" />
|
||||
<dl v-if="formValues.length" class="mt-4">
|
||||
<template v-for="item in formValues" :key="item.title">
|
||||
<dt class="text-n-slate-11 italic mt-2">
|
||||
{{ item.label || t('CONVERSATION.RESPONSE') }}
|
||||
</dt>
|
||||
<dd>{{ item.title }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-else-if="isAWebWidgetInbox" class="my-2 font-medium">
|
||||
{{ t('CONVERSATION.NO_RESPONSE') }}
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
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 '../provider.js';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment.value;
|
||||
try {
|
||||
isDownloading.value = true;
|
||||
await downloadFile({ url: dataUrl, type: fileType, extension });
|
||||
} catch (error) {
|
||||
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
|
||||
} finally {
|
||||
isDownloading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
data-bubble-name="image"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||
<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>
|
||||
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
class="skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
/>
|
||||
<div
|
||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||
/>
|
||||
<div class="absolute right-2 bottom-2 hidden group-hover:flex gap-2">
|
||||
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
|
||||
<Button
|
||||
xs
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-download"
|
||||
class="opacity-60"
|
||||
:is-loading="isDownloading"
|
||||
:disabled="isDownloading"
|
||||
@click.stop="downloadAttachment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="handleImageError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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, ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { t } = useI18n();
|
||||
const { variant, content, contentAttributes, attachments } =
|
||||
useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const isStoryReply = computed(() => {
|
||||
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
|
||||
});
|
||||
|
||||
const hasImgStoryError = ref(false);
|
||||
const hasVideoStoryError = ref(false);
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||
return content.value;
|
||||
}
|
||||
|
||||
return new MessageFormatter(content.value).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">
|
||||
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
|
||||
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
|
||||
</p>
|
||||
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
|
||||
<img
|
||||
v-if="!hasImgStoryError"
|
||||
class="rounded-lg max-w-80 skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<video
|
||||
v-else-if="!hasVideoStoryError"
|
||||
class="rounded-lg max-w-80 skip-context-menu"
|
||||
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>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const lat = computed(() => {
|
||||
return attachment.value.coordinatesLat;
|
||||
});
|
||||
const long = computed(() => {
|
||||
return attachment.value.coordinatesLong;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
return attachment.value.fallbackTitle ?? attachment.value.fallback_title;
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${lat.value},${long.value}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseAttachmentBubble
|
||||
icon="i-ph-navigation-arrow-fill"
|
||||
icon-bg-color="bg-[#0D9B8A]"
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.LOCATION"
|
||||
:content="title"
|
||||
:action="{
|
||||
label: t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP'),
|
||||
href: mapUrl,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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="prose prose-bubble" />
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const renderOriginal = ref(false);
|
||||
|
||||
const renderContent = computed(() => {
|
||||
if (renderOriginal.value) {
|
||||
return content.value;
|
||||
}
|
||||
|
||||
if (hasTranslations.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const isTemplate = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.TEMPLATE;
|
||||
});
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="text">
|
||||
<div class="gap-3 flex flex-col">
|
||||
<span v-if="isEmpty" class="text-n-slate-11">
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="-mt-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import BaseBubble from './Base.vue';
|
||||
|
||||
const { inboxId } = useMessageContext();
|
||||
|
||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
||||
inboxId.value
|
||||
);
|
||||
|
||||
const unsupportedMessageKey = computed(() => {
|
||||
if (isAFacebookInbox.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
|
||||
if (isAnInstagramChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
||||
{{ $t(unsupportedMessageKey) }}
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<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 '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
import { ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const handleError = () => {
|
||||
hasError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const isReel = computed(() => {
|
||||
return attachment.value.fileType === ATTACHMENT_TYPES.IG_REEL;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
data-bubble-name="video"
|
||||
@click="showGallery = true"
|
||||
>
|
||||
<div class="relative group rounded-lg overflow-hidden">
|
||||
<div
|
||||
v-if="isReel"
|
||||
class="absolute p-2 flex items-start justify-end right-0 pointer-events-none"
|
||||
>
|
||||
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
|
||||
</div>
|
||||
<video
|
||||
controls
|
||||
class="rounded-lg skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:class="{
|
||||
'max-w-48': isReel,
|
||||
'max-w-full': !isReel,
|
||||
}"
|
||||
@click.stop
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="onError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
>
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
Reference in New Issue
Block a user