Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,57 @@
<template>
<div class="animation-container margin-top-1">
<div class="ai-typing--wrap">
<fluent-icon icon="wand" size="14" class="ai-typing--icon" />
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.AI_WRITING') }}
</label>
</div>
<span class="loader" />
<span class="loader" />
<span class="loader" />
</div>
</template>
<style lang="scss" scoped>
.animation-container {
position: relative;
display: flex;
.ai-typing--wrap {
display: flex;
align-items: center;
gap: 4px;
.ai-typing--icon {
@apply text-n-iris-11;
}
}
label {
@apply text-n-iris-11 ltr:mr-1 rtl:ml-1 inline-block;
}
.loader {
animation: bubble-scale 1.2s infinite;
@apply bg-n-iris-11 inline-block size-1.5 ltr:mr-1 rtl:ml-1 mt-3 rounded-full;
}
.loader:nth-child(2) {
animation-delay: 0.4s;
}
.loader:nth-child(3) {
animation-delay: 0.8s;
}
@keyframes bubble-scale {
0%,
100% {
transform: scale(1);
}
25% {
transform: scale(1.3);
}
50% {
transform: scale(1);
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import { formatBytes } from 'shared/helpers/FileHelper';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
);
const onRemoveAttachment = itemIndex => {
emit(
'removeAttachment',
nonRecordedAudioAttachments.value
.filter((_, index) => index !== itemIndex)
.concat(recordedAudioAttachments.value)
);
};
const formatFileSize = file => {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
};
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const fileName = file => {
return file.filename || file.name;
};
</script>
<template>
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
v-if="isTypeImage(attachment.resource)"
class="object-cover w-6 h-6 rounded-sm"
:src="attachment.thumb"
/>
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
📄
</span>
</div>
<div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis">
<span
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
>
{{ fileName(attachment.resource) }}
</span>
</div>
<div class="w-[30%] justify-center">
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{{ formatFileSize(attachment.resource) }}
</span>
</div>
<div class="flex items-center justify-center">
<Button
ghost
slate
xs
icon="i-lucide-x"
@click="onRemoveAttachment(index)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,202 @@
<script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
AutomationActionTeamMessageInput,
AutomationActionFileInput,
WootMessageEditor,
NextButton,
SingleSelect,
MultiSelect,
NextInput,
},
props: {
modelValue: {
type: Object,
default: () => null,
},
actionTypes: {
type: Array,
default: () => [],
},
dropdownValues: {
type: Array,
default: () => [],
},
errorMessage: {
type: String,
default: '',
},
showActionInput: {
type: Boolean,
default: true,
},
initialFileName: {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
},
emits: ['update:modelValue', 'input', 'removeAction', 'resetAction'],
computed: {
action_name: {
get() {
if (!this.modelValue) return null;
return this.modelValue.action_name;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, action_name: value });
this.$emit('input', { ...payload, action_name: value });
},
},
action_params: {
get() {
if (!this.modelValue) return null;
return this.modelValue.action_params;
},
set(value) {
const payload = this.modelValue || {};
this.$emit('update:modelValue', { ...payload, action_params: value });
this.$emit('input', { ...payload, action_params: value });
},
},
inputType() {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionNameAsSelectModel: {
get() {
if (!this.action_name) return null;
const found = this.actionTypes.find(a => a.key === this.action_name);
return found ? { id: found.key, name: found.label } : null;
},
set(value) {
this.action_name = value?.id || value;
},
},
actionTypesAsOptions() {
return this.actionTypes.map(a => ({ id: a.key, name: a.label }));
},
isVerticalLayout() {
return ['team_message', 'textarea'].includes(this.inputType);
},
castMessageVmodel: {
get() {
if (Array.isArray(this.action_params)) {
return this.action_params[0];
}
return this.action_params;
},
set(value) {
this.action_params = value;
},
},
},
methods: {
removeAction() {
this.$emit('removeAction');
},
resetAction() {
this.$emit('resetAction');
},
onActionNameChange(value) {
this.actionNameAsSelectModel = value;
this.resetAction();
},
},
};
</script>
<template>
<li class="list-none py-2 first:pt-0 last:pb-0">
<div
class="flex flex-col gap-2"
:class="{ 'animate-wiggle': errorMessage }"
>
<div class="flex items-center gap-2">
<SingleSelect
:model-value="actionNameAsSelectModel"
:options="actionTypesAsOptions"
:dropdown-max-height="dropdownMaxHeight"
disable-deselect
class="flex-shrink-0"
@update:model-value="onActionNameChange"
/>
<template v-if="showActionInput && !isVerticalLayout">
<SingleSelect
v-if="inputType === 'search_select'"
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<MultiSelect
v-else-if="inputType === 'multi_select'"
v-model="action_params"
:options="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<NextInput
v-else-if="inputType === 'email'"
v-model="action_params"
type="email"
size="sm"
:placeholder="$t('AUTOMATION.ACTION.EMAIL_INPUT_PLACEHOLDER')"
/>
<NextInput
v-else-if="inputType === 'url'"
v-model="action_params"
type="url"
size="sm"
:placeholder="$t('AUTOMATION.ACTION.URL_INPUT_PLACEHOLDER')"
/>
<AutomationActionFileInput
v-else-if="inputType === 'attachment'"
v-model="action_params"
:initial-file-name="initialFileName"
/>
</template>
<NextButton
v-if="!isMacro"
sm
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="removeAction"
/>
</div>
<AutomationActionTeamMessageInput
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
:dropdown-max-height="dropdownMaxHeight"
/>
<WootMessageEditor
v-if="inputType === 'textarea'"
v-model="castMessageVmodel"
rows="4"
enable-variables
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="[&_.ProseMirror-menubar]:hidden px-3 py-1 bg-n-alpha-1 rounded-lg outline outline-1 outline-n-weak dark:outline-n-strong"
/>
</div>
<span v-if="errorMessage" class="text-sm text-n-ruby-11">
{{ errorMessage }}
</span>
</li>
</template>

View File

@@ -0,0 +1,52 @@
<script>
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
export default {
components: {
MultiSelect,
},
props: {
teams: { type: Array, required: true },
modelValue: { type: Object, required: true },
dropdownMaxHeight: { type: String, default: 'max-h-80' },
},
emits: ['update:modelValue'],
data() {
return {
selectedTeams: [],
message: '',
};
},
mounted() {
const { team_ids: teamIds, message } = this.modelValue || {};
this.selectedTeams = teamIds || [];
this.message = message || '';
},
methods: {
updateValue() {
this.$emit('update:modelValue', {
team_ids: this.selectedTeams.map(team => team.id),
message: this.message,
});
},
},
};
</script>
<template>
<div class="flex flex-col gap-2">
<MultiSelect
v-model="selectedTeams"
:options="teams"
:dropdown-max-height="dropdownMaxHeight"
@update:model-value="updateValue"
/>
<textarea
v-model="message"
class="mb-0 !text-sm"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
/>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script>
import { useAlert } from 'dashboard/composables';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
initialFileName: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
data() {
return {
uploadState: 'idle',
label: this.$t('AUTOMATION.ATTACHMENT.LABEL_IDLE'),
};
},
mounted() {
if (this.initialFileName) {
this.label = this.initialFileName;
this.uploadState = 'uploaded';
}
},
methods: {
async onChangeFile(event) {
this.uploadState = 'processing';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
try {
const file = event.target.files[0];
const id = await this.$store.dispatch(
'automations/uploadAttachment',
file
);
this.$emit('update:modelValue', [id]);
this.uploadState = 'uploaded';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
} catch (error) {
this.uploadState = 'failed';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
useAlert(this.$t('AUTOMATION.ATTACHMENT.UPLOAD_ERROR'));
}
},
},
};
</script>
<template>
<label class="input-wrapper" :class="uploadState">
<input
v-if="uploadState !== 'processing'"
type="file"
name="attachment"
:class="uploadState === 'processing' ? 'disabled' : ''"
@change="onChangeFile"
/>
<Spinner v-if="uploadState === 'processing'" />
<fluent-icon v-if="uploadState === 'idle'" icon="file-upload" />
<fluent-icon
v-if="uploadState === 'uploaded'"
icon="checkmark-circle"
type="outline"
class="success-icon"
/>
<fluent-icon
v-if="uploadState === 'failed'"
icon="dismiss-circle"
type="outline"
class="error-icon"
/>
<p class="file-button">{{ label }}</p>
</label>
</template>
<style scoped>
input[type='file'] {
@apply hidden;
}
.input-wrapper {
@apply flex h-8 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-lg border border-dashed border-n-strong;
}
.success-icon {
@apply text-n-teal-9 mr-2;
}
.error-icon {
@apply text-n-ruby-9 mr-2;
}
.processing {
@apply cursor-not-allowed opacity-90;
}
.file-button {
@apply whitespace-nowrap overflow-hidden text-ellipsis w-full mb-0;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import router from '../../routes/index';
const props = defineProps({
backUrl: {
type: [String, Object],
default: '',
},
buttonLabel: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const goBack = () => {
if (props.backUrl !== '') {
router.push(props.backUrl);
} else {
router.go(-1);
}
};
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
:class="buttonStyleClass"
@click.capture="goBack"
>
<i class="i-lucide-chevron-left -ml-1 text-lg" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
const props = defineProps({
channel: {
type: Object,
required: true,
},
enabledFeatures: {
type: Object,
required: true,
},
});
const emit = defineEmits(['channelItemClick']);
const hasFbConfigured = computed(() => {
return window.chatwootConfig?.fbAppId;
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
return false;
}
if (key === 'website') {
return props.enabledFeatures.channel_website;
}
if (key === 'facebook') {
return props.enabledFeatures.channel_facebook && hasFbConfigured.value;
}
if (key === 'email') {
return props.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return (
props.enabledFeatures.channel_instagram && hasInstagramConfigured.value
);
}
if (key === 'tiktok') {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
'api',
'whatsapp',
'sms',
'telegram',
'line',
'instagram',
'tiktok',
'voice',
].includes(key);
});
const isComingSoon = computed(() => {
const { key } = props.channel;
// Show "Coming Soon" only if the channel is marked as coming soon
// and the corresponding feature flag is not enabled yet.
return ['voice'].includes(key) && !isActive.value;
});
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
}
};
</script>
<template>
<ChannelSelector
:title="channel.title"
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:disabled="!isActive"
@click="onItemClick"
/>
</template>

View File

@@ -0,0 +1,64 @@
<script setup>
import { computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
});
const emit = defineEmits(['chatTabChange']);
const activeTabIndex = computed(() => {
return props.items.findIndex(item => item.key === props.activeTab);
});
const onTabChange = selectedTabIndex => {
if (selectedTabIndex >= 0 && selectedTabIndex < props.items.length) {
const selectedItem = props.items[selectedTabIndex];
if (selectedItem.key !== props.activeTab) {
emit('chatTabChange', selectedItem.key);
}
}
};
const keyboardEvents = {
'Alt+KeyN': {
action: () => {
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
onTabChange(0);
} else {
const nextIndex = (activeTabIndex.value + 1) % props.items.length;
onTabChange(nextIndex);
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm [&_a]:font-medium"
:index="index"
:name="item.name"
:count="item.count"
is-compact
/>
</woot-tabs>
</template>

View File

@@ -0,0 +1,72 @@
<script>
import { Chrome } from '@lk77/vue3-color';
export default {
components: {
Chrome,
},
props: {
modelValue: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
data() {
return {
isPickerOpen: false,
};
},
methods: {
closeTogglePicker() {
if (this.isPickerOpen) {
this.toggleColorPicker();
}
},
toggleColorPicker() {
this.isPickerOpen = !this.isPickerOpen;
},
updateColor(e) {
this.$emit('update:modelValue', e.hex);
},
},
};
</script>
<template>
<div class="colorpicker">
<div
class="colorpicker--selected"
:style="`background-color: ${modelValue}`"
@click.prevent="toggleColorPicker"
/>
<Chrome
v-if="isPickerOpen"
v-on-clickaway="closeTogglePicker"
disable-alpha
:model-value="modelValue"
class="colorpicker--chrome"
@update:model-value="updateColor"
/>
</div>
</template>
<style scoped lang="scss">
.colorpicker {
position: relative;
}
.colorpicker--selected {
@apply border border-solid border-n-weak rounded cursor-pointer h-8 w-8 mb-4;
}
.colorpicker--chrome.vc-chrome {
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
::v-deep {
input {
@apply bg-white dark:bg-white;
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
export default {
components: {
LoadingState,
},
props: {
config: {
type: Array,
default: () => [],
},
currentChat: {
type: Object,
default: () => ({}),
},
isVisible: {
type: Boolean,
default: false,
},
position: {
type: Number,
required: true,
},
},
data() {
return {
hasOpenedAtleastOnce: false,
iframeLoading: true,
};
},
computed: {
dashboardAppContext() {
return {
conversation: this.currentChat,
contact: this.$store.getters['contacts/getContact'](this.contactId),
currentAgent: this.currentAgent,
};
},
contactId() {
return this.currentChat?.meta?.sender?.id;
},
currentAgent() {
const { id, name, email } = this.$store.getters.getCurrentUser;
return { id, name, email };
},
},
watch: {
isVisible() {
if (this.isVisible) {
this.hasOpenedAtleastOnce = true;
}
},
},
mounted() {
window.addEventListener('message', this.triggerEvent);
},
unmounted() {
window.removeEventListener('message', this.triggerEvent);
},
methods: {
triggerEvent(event) {
if (!this.isVisible) return;
if (event.data === 'chatwoot-dashboard-app:fetch-info') {
this.onIframeLoad(0);
}
},
getFrameId(index) {
return `dashboard-app--frame-${this.position}-${index}`;
},
onIframeLoad(index) {
// A possible alternative is to use ref instead of document.getElementById
// However, when ref is used together with v-for, the ref you get will be
// an array containing the child components mirroring the data source.
const frameElement = document.getElementById(this.getFrameId(index));
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
class="dashboard-app--list"
>
<LoadingState
v-if="iframeLoading"
:message="$t('DASHBOARD_APPS.LOADING_MESSAGE')"
class="dashboard-app_loading-container"
/>
<iframe
v-if="configItem.type === 'frame' && configItem.url"
:id="getFrameId(index)"
:src="configItem.url"
@load="() => onIframeLoad(index)"
/>
</div>
</div>
</template>
<style scoped>
.dashboard-app--container,
.dashboard-app--list,
.dashboard-app--list iframe {
height: 100%;
width: 100%;
}
.dashboard-app--list iframe {
border: 0;
}
.dashboard-app_loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,26 @@
<script>
export default {
props: {
title: { type: String, default: '' },
message: { type: String, default: '' },
},
};
</script>
<template>
<div class="empty-state py-16 px-1 ml-0 mr-0">
<h3
v-if="title"
class="text-n-slate-12 block text-center w-full text-xl font-medium"
>
{{ title }}
</h3>
<p
v-if="message"
class="block text-center text-n-slate-11 my-4 mx-auto w-[90%]"
>
{{ message }}
</p>
<slot />
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script>
import { mapGetters } from 'vuex';
export default {
props: {
featureKey: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
accountId: 'getCurrentAccountId',
}),
isFeatureEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isFeatureEnabled">
<slot />
</div>
</template>

View File

@@ -0,0 +1,90 @@
export const OPERATOR_TYPES_1 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
];
export const OPERATOR_TYPES_2 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
];
export const OPERATOR_TYPES_3 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'contains',
label: 'Contains',
},
{
value: 'does_not_contain',
label: 'Does not contain',
},
];
export const OPERATOR_TYPES_4 = [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
{
value: 'is_greater_than',
label: 'Is greater than',
},
{
value: 'is_less_than',
label: 'Is less than',
},
];
export const OPERATOR_TYPES_5 = [
{
value: 'is_greater_than',
label: 'Is greater than',
},
{
value: 'is_less_than',
label: 'Is less than',
},
{
value: 'days_before',
label: 'Is x days before',
},
];

View File

@@ -0,0 +1,15 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from './FilterOperatorTypes';
describe('#filterOperators', () => {
it('Matches the correct Operators', () => {
expect(OPERATOR_TYPES_1).toMatchObject(OPERATOR_TYPES_1);
expect(OPERATOR_TYPES_2).toMatchObject(OPERATOR_TYPES_2);
expect(OPERATOR_TYPES_3).toMatchObject(OPERATOR_TYPES_3);
expect(OPERATOR_TYPES_4).toMatchObject(OPERATOR_TYPES_4);
});
});

View File

@@ -0,0 +1,184 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
message: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-center p-8">
<h6
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
>
<span class="text-body-main !text-base text-n-slate-12">
{{ message }}
</span>
<Spinner class="text-n-brand" />
</h6>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script>
export default {
props: {
headerTitle: {
type: String,
default: '',
},
headerContent: {
type: String,
default: '',
},
},
};
</script>
<template>
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-7xl w-full mx-auto pt-4 pb-0 px-6">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p v-if="headerContent" class="w-full text-n-slate-11 text-sm mb-2">
{{ headerContent }}
</p>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
});
const { t } = useI18n();
const showMore = ref(false);
const textToBeDisplayed = computed(() => {
if (showMore.value || props.text.length <= props.limit) {
return props.text;
}
return props.text.slice(0, props.limit) + '...';
});
const buttonLabel = computed(() => {
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
});
const toggleShowMore = () => {
showMore.value = !showMore.value;
};
</script>
<template>
<span>
{{ textToBeDisplayed }}
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click.stop="toggleShowMore"
>
{{ buttonLabel }}
</button>
</span>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import TableFooterResults from './TableFooterResults.vue';
import TableFooterPagination from './TableFooterPagination.vue';
const props = defineProps({
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 25,
},
totalCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['pageChange']);
const totalPages = computed(() => Math.ceil(props.totalCount / props.pageSize));
const firstIndex = computed(() => props.pageSize * (props.currentPage - 1) + 1);
const lastIndex = computed(() =>
Math.min(props.totalCount, props.pageSize * props.currentPage)
);
const isFooterVisible = computed(
() => props.totalCount && !(firstIndex.value > props.totalCount)
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<footer
v-if="isFooterVisible"
class="flex items-center justify-between h-12 px-6"
>
<TableFooterResults
:first-index="firstIndex"
:last-index="lastIndex"
:total-count="totalCount"
/>
<TableFooterPagination
v-if="totalCount"
:current-page="currentPage"
:total-pages="totalPages"
:total-count="totalCount"
:page-size="pageSize"
@page-change="emit('pageChange', $event)"
/>
</footer>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
currentPage: {
type: Number,
default: 1,
},
totalPages: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['pageChange']);
const hasLastPage = computed(
() => props.currentPage === props.totalPages || props.totalPages === 1
);
const hasFirstPage = computed(() => props.currentPage === 1);
const hasNextPage = computed(() => props.currentPage === props.totalPages);
const hasPrevPage = computed(() => props.currentPage === 1);
function onPageChange(newPage) {
emit('pageChange', newPage);
}
const onNextPage = () => {
if (!onNextPage.value) {
onPageChange(props.currentPage + 1);
}
};
const onPrevPage = () => {
if (!hasPrevPage.value) {
onPageChange(props.currentPage - 1);
}
};
const onFirstPage = () => {
if (!hasFirstPage.value) {
onPageChange(1);
}
};
const onLastPage = () => {
if (!hasLastPage.value) {
onPageChange(props.totalPages);
}
};
</script>
<template>
<div
class="flex items-center h-8 outline outline-1 outline-n-weak rounded-lg"
>
<NextButton
faded
sm
slate
icon="i-lucide-chevrons-left"
class="ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
:disabled="hasFirstPage"
@click="onFirstPage"
/>
<div class="flex items-center justify-center bg-n-slate-9/10 h-full">
<div class="w-px h-4 rounded-sm bg-n-strong" />
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevron-left"
class="rounded-none"
:disabled="hasPrevPage"
@click="onPrevPage"
/>
<div
class="flex items-center gap-3 px-3 tabular-nums bg-n-slate-9/10 h-full"
>
<span class="text-sm text-n-slate-12">
{{ currentPage }}
</span>
<span class="text-n-slate-11">/</span>
<span class="text-sm text-n-slate-11">
{{ totalPages }}
</span>
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevron-right"
class="rounded-none"
:disabled="hasNextPage"
@click="onNextPage"
/>
<div class="flex items-center justify-center bg-n-slate-9/10 h-full">
<div class="w-px h-4 rounded-sm bg-n-strong" />
</div>
<NextButton
faded
sm
slate
icon="i-lucide-chevrons-right"
class="ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
:disabled="hasLastPage"
@click="onLastPage"
/>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
defineProps({
firstIndex: {
type: Number,
default: 0,
},
lastIndex: {
type: Number,
default: 0,
},
totalCount: {
type: Number,
default: 0,
},
});
</script>
<template>
<span class="text-sm text-n-slate-11 font-medium">
{{
$t('GENERAL.SHOWING_RESULTS', {
firstIndex,
lastIndex,
totalCount,
})
}}
</span>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
span: {
type: Number,
required: true,
},
label: {
type: String,
required: true,
},
});
const spanClass = computed(() => {
if (props.span === 1) return 'col-span-1';
if (props.span === 2) return 'col-span-2';
if (props.span === 3) return 'col-span-3';
if (props.span === 4) return 'col-span-4';
if (props.span === 5) return 'col-span-5';
if (props.span === 6) return 'col-span-6';
if (props.span === 7) return 'col-span-7';
if (props.span === 8) return 'col-span-8';
if (props.span === 9) return 'col-span-9';
if (props.span === 10) return 'col-span-10';
return 'col-span-1';
});
</script>
<template>
<div
class="flex items-center px-0 py-2 text-xs font-medium text-right uppercase text-n-slate-11 rtl:text-left"
:class="spanClass"
>
<slot>
{{ label }}
</slot>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
const props = defineProps({
usersList: {
type: Array,
default: () => [],
},
size: {
type: Number,
default: 24,
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', 'tight'].includes(value);
},
},
});
const gapClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
}
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const moreThumbnailsClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
return 'ltr:-ml-1 rtl:-mr-1';
});
</script>
<template>
<div class="flex">
<Avatar
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:name="user.name"
:size="size"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
:class="gapClass"
rounded-full
/>
<span
v-if="showMoreThumbnailsCount"
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
:class="moreThumbnailsClass"
>
{{ moreThumbnailsText }}
</span>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
user: {
type: Object,
default: () => ({}),
},
size: {
type: Number,
default: 20,
},
textClass: {
type: String,
default: 'text-sm text-n-slate-12',
},
});
</script>
<template>
<div class="flex items-center gap-1.5 text-left">
<Avatar
:src="user.thumbnail"
:size="size"
:name="user.name"
:status="user.availability_status"
hide-offline-status
rounded-full
/>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script>
import { mapGetters } from 'vuex';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
conversationId: {
type: Number,
default: 0,
},
},
data() {
return { isLoading: false };
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isVideoIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'dyte' && !!integration.hooks.length
);
},
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
},
methods: {
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
} finally {
this.isLoading = false;
}
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<NextButton
v-if="isVideoIntegrationEnabled"
v-tooltip.top-end="
$t('INTEGRATION_SETTINGS.DYTE.START_VIDEO_CALL_HELP_TEXT')
"
icon="i-ph-video-camera"
slate
faded
sm
@click="onClick"
/>
</template>

View File

@@ -0,0 +1,122 @@
<script setup>
import getUuid from 'widget/helpers/uuid';
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/mp3ConversionUtils';
const props = defineProps({
audioRecordFormat: {
type: String,
required: true,
},
});
const emit = defineEmits([
'recorderProgressChanged',
'finishRecord',
'pause',
'play',
]);
const waveformContainer = ref(null);
const wavesurfer = ref(null);
const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
return format(
new Date(0, 0, 0, 0, duration.minutes, duration.seconds),
'mm:ss'
);
};
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
waveColor: '#1F93FF',
progressColor: '#6E6F73',
height: 100,
barWidth: 2,
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
],
});
wavesurfer.value.on('pause', () => emit('pause'));
wavesurfer.value.on('play', () => emit('play'));
record.value = wavesurfer.value.plugins[0];
wavesurfer.value.on('finish', () => {
isPlaying.value = false;
});
record.value.on('record-end', async blob => {
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
});
record.value.on('record-progress', time => {
emit('recorderProgressChanged', formatTimeProgress(time));
});
};
const stopRecording = () => {
if (isRecording.value) {
record.value.stopRecording();
isRecording.value = false;
}
};
const startRecording = () => {
record.value.startRecording();
isRecording.value = true;
};
const playPause = () => {
if (hasRecording.value) {
wavesurfer.value.playPause();
isPlaying.value = !isPlaying.value;
}
};
onMounted(() => {
initWaveSurfer();
startRecording();
});
onUnmounted(() => {
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
});
defineExpose({ playPause, stopRecording, record });
</script>
<template>
<div ref="waveformContainer" class="w-full p-1" />
</template>

View File

@@ -0,0 +1,253 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const handleSubmit = () => emit('send');
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
/>
</div>
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>

View File

@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
import { REPLY_EDITOR_MODES } from './constants';
const props = defineProps({
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disabled: {
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
const wootEditorReplyMode = useTemplateRef('wootEditorReplyMode');
const wootEditorPrivateMode = useTemplateRef('wootEditorPrivateMode');
const replyModeSize = useElementSize(wootEditorReplyMode);
const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
* Computes the width of the sliding background chip in pixels
* Includes 16px of padding in the calculation
* @type {ComputedRef<string>}
*/
const width = computed(() => {
const widthToUse = isPrivate.value
? privateModeSize.width.value
: replyModeSize.width.value;
const widthWithPadding = widthToUse + 16;
return `${widthWithPadding}px`;
});
/**
* Computes the X translation value for the sliding background chip
* Translates by the width of reply mode + padding when in private mode
* @type {ComputedRef<string>}
*/
const translateValue = computed(() => {
const xTranslate = isPrivate.value ? replyModeSize.width.value + 16 : 0;
return `${xTranslate}px`;
});
</script>
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
</div>
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
'--rtl-translate-x': `calc(-1 * var(--translate-x))`,
}"
/>
</button>
</template>

View File

@@ -0,0 +1,351 @@
<script>
import {
fullSchema,
buildEditor,
EditorView,
ArticleMarkdownSerializer,
ArticleMarkdownTransformer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
plugins: buildEditor({
schema: fullSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
let editorView = null;
let state;
export default {
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
enabledMenuOptions: { type: Array, default: () => [] },
autofocus: {
type: Boolean,
default: true,
},
},
emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
};
},
watch: {
modelValue(newValue = '') {
if (newValue !== this.contentFromEditor()) {
this.reloadState();
}
},
editorId() {
this.reloadState();
},
},
created() {
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
},
mounted() {
this.createEditorView();
editorView.updateState(state);
if (this.autofocus) {
this.focusEditorInputField();
}
},
methods: {
contentFromEditor() {
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
},
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
async handleImageUpload(url) {
try {
const fileUrl = await this.$store.dispatch(
'articles/uploadExternalImage',
{
portalSlug: this.$route.params.portalSlug,
url,
}
);
return fileUrl;
} catch (error) {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
);
return '';
}
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
size: MAXIMUM_FILE_UPLOAD_SIZE,
})
);
}
this.$refs.imageUploadInput.value = '';
},
async uploadImageToStorage(file) {
try {
const fileUrl = await this.$store.dispatch('articles/attachImage', {
portalSlug: this.$route.params.portalSlug,
file,
});
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
} catch (error) {
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
}
},
onImageUploadStart(fileUrl) {
const { selection } = editorView.state;
const from = selection.from;
const node = editorView.state.schema.nodes.image.create({
src: fileUrl,
});
const paragraphNode = editorView.state.schema.node('paragraph');
if (node) {
// Insert the image and the caption wrapped inside a paragraph
const tr = editorView.state.tr
.replaceSelectionWith(paragraphNode)
.insert(from + 1, node);
editorView.dispatch(tr.scrollIntoView());
this.focusEditorInputField();
}
},
reloadState() {
state = createState(
this.modelValue,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.enabledMenuOptions
);
editorView.updateState(state);
this.focusEditorInputField();
},
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
this.emitOnChange();
}
this.checkSelection(state);
},
handleDOMEvents: {
keyup: this.onKeyup,
focus: this.onFocus,
blur: this.onBlur,
keydown: this.onKeydown,
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
data.forEach(file => {
// Check if the file is an image
if (file.type.includes('image')) {
this.uploadImageToStorage(file);
}
});
event.preventDefault();
}
},
},
});
},
handleKeyEvents() {},
focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
},
emitOnChange() {
this.$emit('update:modelValue', this.contentFromEditor());
this.$emit('input', this.contentFromEditor());
},
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
this.$emit('keydown');
},
onBlur() {
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
this.isTextSelected = hasSelection;
const { editor } = this.$refs;
// Toggle the 'has-selection' class based on whether there's a selection
editor.classList.toggle('has-selection', hasSelection);
// If there's a selection, update the menubar position
if (hasSelection) this.setMenubarPosition(editorState);
},
setMenubarPosition(editorState) {
if (!editorState.selection) return;
// Get the start and end positions of the selection
const { from, to } = editorState.selection;
const { editor } = this.$refs;
// Get the editor's position relative to the viewport
const { left: editorLeft, top: editorTop } =
editor.getBoundingClientRect();
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
// Get the start position of the selection
const { left: startLeft } = editorView.coordsAtPos(from);
// Calculate the top position for the menubar (10px below the selection)
const top = endBottom - editorTop + 10;
// Calculate the left position for the menubar
// This centers the menubar on the selection while keeping it within the editor's bounds
const left = Math.max(
0,
Math.min(
(startLeft + endRight) / 2 - editorLeft,
editorWidth - menubarWidth
)
);
// Set the CSS custom properties for positioning the menubar
editor.style.setProperty('--selection-top', `${top}px`);
editor.style.setProperty('--selection-left', `${left}px`);
},
},
};
</script>
<template>
<div>
<div class="editor-root editor--article">
<input
ref="imageUploadInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" />
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/article.scss';
.ProseMirror-menubar-wrapper {
display: flex;
flex-direction: column;
> .ProseMirror {
padding: 0;
word-break: break-word;
}
}
.editor-root {
position: relative;
width: 100%;
}
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>

View File

@@ -0,0 +1,439 @@
<script>
import { ref } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
default: () => {},
},
sendButtonText: {
type: String,
default: '',
},
recordingAudioDurationText: {
type: String,
default: '00:00',
},
// inbox prop is used in /mixins/inboxMixin,
// remove this props when refactoring to composable if not needed
// eslint-disable-next-line vue/no-unused-properties
inbox: {
type: Object,
default: () => ({}),
},
showFileUpload: {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
},
toggleEmojiPicker: {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
},
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
},
newConversationModalActive: {
type: Boolean,
default: false,
},
portalSlug: {
type: String,
required: true,
},
conversationType: {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup(props) {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings();
const uploadRef = ref(false);
const keyboardEvents = {
'$mod+Alt+KeyA': {
action: () => {
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
if (props.isEditorDisabled) return;
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = document.querySelector(
'#conversationAttachment'
);
if (uploadTriggerButton) uploadTriggerButton.click();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings,
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
wrapClass() {
return {
'is-note-mode': this.isNote,
};
},
showAttachButton() {
if (this.isEditorDisabled) return false;
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
// const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
// navigator.userAgent
// );
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder
// !isSafari
);
},
showAudioPlayStopButton() {
if (this.isEditorDisabled) return false;
return this.showAudioRecorder && this.isRecordingAudio;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
// Use default file types for private notes
if (this.isOnPrivateNote) {
return this.ALLOWED_FILE_TYPES;
}
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
return getAllowedFileTypesByChannel({
channelType,
medium: this.inbox?.medium,
});
},
enableDragAndDrop() {
return !this.newConversationModalActive;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'i-ph-pause';
case 'paused':
return 'i-ph-play';
case 'stopped':
return 'i-ph-play';
default:
return 'i-ph-stop';
}
},
showMessageSignatureButton() {
if (this.isEditorDisabled) return false;
return !this.isOnPrivateNote;
},
sendWithSignature() {
// channelType is sourced from inboxMixin
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
enableInsertArticleInReply() {
return this.portalSlug;
},
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
},
methods: {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},
},
};
</script>
<template>
<div class="flex justify-between p-3" :class="wrapClass">
<div class="left-wrap">
<NextButton
v-if="!isEditorDisabled"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="i-ph-smiley-sticker"
slate
faded
sm
@click="toggleEmojiPicker"
/>
<FileUpload
v-if="showAttachButton"
ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
:multiple="enableMultipleFileUpload"
:drop="enableDragAndDrop"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<NextButton
v-if="showAttachButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
icon="i-ph-paperclip"
slate
faded
sm
/>
</FileUpload>
<NextButton
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
slate
faded
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
slate
faded
sm
:label="recordingAudioDurationText"
@click="toggleAudioRecorderPlayPause"
/>
<NextButton
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
icon="i-ph-signature"
slate
faded
sm
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
<NextButton
v-if="enableInsertArticleInReply"
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
icon="i-ph-article-ny-times"
slate
faded
sm
@click="toggleInsertArticle"
/>
</div>
<div class="right-wrap">
<NextButton
:label="sendButtonText"
type="submit"
sm
:color="isNote ? 'amber' : 'blue'"
:disabled="isSendDisabled"
class="flex-shrink-0"
@click="onSend"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.left-wrap {
@apply items-center flex gap-2;
}
.right-wrap {
@apply flex;
}
::v-deep .file-uploads {
label {
@apply cursor-pointer;
}
&:hover button {
@apply enabled:bg-n-slate-9/20;
}
}
</style>

View File

@@ -0,0 +1,190 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isEditorDisabled: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
default: null,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
},
charactersRemaining: {
type: Number,
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
if (props.isReplyRestricted) return;
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
setReplyMode(REPLY_EDITOR_MODES.NOTE);
};
const handleModeToggle = () => {
const newMode =
props.mode === REPLY_EDITOR_MODES.REPLY
? REPLY_EDITOR_MODES.NOTE
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
const isOpening = !showCopilotMenu.value;
if (isOpening) {
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
conversationId: props.conversationId,
entryPoint: 'top_panel',
});
}
showCopilotMenu.value = isOpening;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => handleReplyClick(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
handleModeToggle,
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
replyButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.REPLY,
};
},
noteButtonClass() {
return {
'is-active': this.mode === REPLY_EDITOR_MODES.NOTE,
};
},
charLengthClass() {
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
},
characterLengthWarning() {
return this.charactersRemaining < 0
? `${-this.charactersRemaining} ${CHAR_LENGTH_WARNING.NEGATIVE}`
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
},
},
};
</script>
<template>
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
</span>
</div>
</div>
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled || isEditorDisabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
export const REPLY_EDITOR_MODES = {
REPLY: 'REPLY',
NOTE: 'NOTE',
};
export const CHAR_LENGTH_WARNING = {
UNDER_50: 'characters remaining',
NEGATIVE: 'characters over',
};

View File

@@ -0,0 +1,70 @@
<script setup>
import { shallowRef, computed, onMounted } from 'vue';
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
import MentionBox from '../mentions/MentionBox.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectEmoji']);
const allEmojis = shallowRef([]);
const items = computed(() => {
if (!props.searchKey) return [];
const searchTerm = props.searchKey.toLowerCase();
return allEmojis.value.filter(emoji =>
emoji.searchString.includes(searchTerm)
);
});
function loadEmojis() {
allEmojis.value = emojiGroups.flatMap(({ emojis }) =>
emojis.map(({ name, slug, ...rest }) => ({
...rest,
name,
slug,
searchString: `${name.replace(/\s+/g, '')} ${slug}`.toLowerCase(), // Remove all whitespace and convert to lowercase
}))
);
}
function handleMentionClick(item = {}) {
emit('selectEmoji', item.emoji);
}
onMounted(() => {
loadEmojis();
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="emoji"
:items="items"
@mention-select="handleMentionClick"
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-n-slate-12 group-hover:text-n-brand truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-n-brand': selected,
'font-normal': !selected,
}"
>
:{{ item.name }}
</p>
</span>
</template>
</MentionBox>
</template>

View File

@@ -0,0 +1,146 @@
import lamejs from '@breezystack/lamejs';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const bufferToWav = async (buffer, numChannels, sampleRate) => {
const length = buffer.length * numChannels * 2;
const wav = new ArrayBuffer(44 + length);
const view = new DataView(wav);
// WAV Header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, length, true);
// WAV Data
const offset = 44;
// eslint-disable-next-line no-plusplus
for (let i = 0; i < buffer.length; i++) {
// eslint-disable-next-line no-plusplus
for (let channel = 0; channel < numChannels; channel++) {
const sample = Math.max(
-1,
Math.min(1, buffer.getChannelData(channel)[i])
);
view.setInt16(
offset + (i * numChannels + channel) * 2,
sample * 0x7fff,
true
);
}
}
return new Blob([wav], { type: 'audio/wav' });
};
const decodeAudioData = async audioBlob => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioData = await audioContext.decodeAudioData(arrayBuffer);
return audioData;
};
export const convertToWav = async audioBlob => {
const audioBuffer = await decodeAudioData(audioBlob);
return bufferToWav(
audioBuffer,
audioBuffer.numberOfChannels,
audioBuffer.sampleRate
);
};
/**
* Encodes audio samples to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples, bitrate = 128) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, bitrate);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
const sampleSlice = samples.subarray(offset, sliceEnd);
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
if (mp3Buffer.length > 0) {
outputBuffer.push(new Int8Array(mp3Buffer));
}
}
const remainingData = encoder.flush();
if (remainingData.length > 0) {
outputBuffer.push(new Int8Array(remainingData));
}
return new Blob(outputBuffer, { type: 'audio/mp3' });
};
/**
* Converts an audio Blob to an MP3 format Blob.
* @param {Blob} audioBlob - The audio data as a Blob.
* @param {number} bitrate - MP3 bitrate (default: 128)
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertToMp3 = async (audioBlob, bitrate = 128) => {
try {
const audioBuffer = await decodeAudioData(audioBlob);
const samples = new Int16Array(
audioBuffer.length * audioBuffer.numberOfChannels
);
let offset = 0;
for (let i = 0; i < audioBuffer.length; i += 1) {
for (
let channel = 0;
channel < audioBuffer.numberOfChannels;
channel += 1
) {
const sample = Math.max(
-1,
Math.min(1, audioBuffer.getChannelData(channel)[i])
);
samples[offset] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
offset += 1;
}
}
return encodeToMP3(
audioBuffer.numberOfChannels,
audioBuffer.sampleRate,
samples,
bitrate
);
} catch (error) {
throw new Error('Conversion to MP3 failed.');
}
};
export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
let audio;
if (outputFormat === 'audio/wav') {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else {
throw new Error('Unsupported output format');
}
return audio;
};

View File

@@ -0,0 +1,52 @@
<script>
import { mapGetters } from 'vuex';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
emits: ['replace'],
computed: {
...mapGetters({
cannedMessages: 'getCannedResponses',
}),
items() {
return this.cannedMessages.map(cannedMessage => ({
label: cannedMessage.short_code,
key: cannedMessage.short_code,
description: cannedMessage.content,
}));
},
},
watch: {
searchKey() {
this.fetchCannedResponses();
},
},
mounted() {
this.fetchCannedResponses();
},
methods: {
fetchCannedResponses() {
this.$store.dispatch('getCannedResponse', { searchKey: this.searchKey });
},
handleMentionClick(item = {}) {
this.$emit('replace', item.description);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
:items="items"
@mention-select="handleMentionClick"
/>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TemplatesPicker from './ContentTemplatesPicker.vue';
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
const { t } = useI18n();
const selectedContentTemplate = ref(null);
const localShow = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
},
});
const modalHeaderContent = computed(() => {
return selectedContentTemplate.value
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: selectedContentTemplate.value.friendly_name,
})
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
});
const pickTemplate = template => {
selectedContentTemplate.value = template;
};
const onResetTemplate = () => {
selectedContentTemplate.value = null;
};
const onSendMessage = message => {
emit('onSend', message);
};
const onClose = () => {
emit('cancel');
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="px-8 py-6 row">
<TemplatesPicker
v-if="!selectedContentTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedContentTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<div class="flex gap-2 mt-6">
<Button
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
color="slate"
variant="faded"
class="flex-1"
@click="resetTemplate"
/>
<Button
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
class="flex-1"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</TemplateParser>
</div>
</woot-modal>
</template>

View File

@@ -0,0 +1,172 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const twilioTemplates = computed(() => {
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
return inbox?.content_templates?.templates || [];
});
const filteredTemplateMessages = computed(() =>
twilioTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(query.value.toLowerCase()) && template.status === 'approved'
)
);
const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div
v-for="(template, i) in filteredTemplateMessages"
:key="template.content_sid"
>
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.friendly_name }}
</p>
<div class="flex gap-2">
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ getTemplateType(template) }}
</span>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
}}
</span>
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
</p>
<p class="text-sm label-body">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm">{{ template.category || 'utility' }}</p>
</div>
<div class="text-xs text-n-slate-11">
{{ new Date(template.created_at).toLocaleDateString() }}
</div>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && twilioTemplates.length">
<p>
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!twilioTemplates.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,178 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import wootConstants from 'dashboard/constants/globals';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
isOnExpandedLayout: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['changeFilter']);
const store = useStore();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
return chatStatusFilter.value || wootConstants.STATUS_TYPE.OPEN;
});
const currentSortBy = computed(() => {
return (
chatSortFilter.value || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
});
const chatStatusOptions = computed(() => [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
value: 'open',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
value: 'resolved',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
value: 'pending',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
value: 'snoozed',
},
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
value: 'all',
},
]);
const chatSortOptions = computed(() => [
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
value: 'last_activity_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_desc.TEXT'),
value: 'last_activity_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_desc.TEXT'),
value: 'created_at_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
value: 'waiting_since_desc',
},
]);
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
?.label || ''
);
const activeChatSortLabel = computed(
() =>
chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label ||
''
);
const saveSelectedFilter = (type, value) => {
updateUISettings({
conversations_filter_by: {
status: type === 'status' ? value : currentStatusFilter.value,
order_by: type === 'sort' ? value : currentSortBy.value,
},
});
};
const handleStatusChange = value => {
emit('changeFilter', value, 'status');
store.dispatch('setChatStatusFilter', value);
saveSelectedFilter('status', value);
};
const handleSortChange = value => {
emit('changeFilter', value, 'sort');
store.dispatch('setChatSortFilter', value);
saveSelectedFilter('sort', value);
};
</script>
<template>
<div class="relative flex">
<NextButton
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
slate
faded
xs
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
v-on-click-outside="() => toggleDropdown()"
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
}"
>
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
</span>
<SelectMenu
:model-value="chatStatusFilter"
:options="chatStatusOptions"
:label="activeChatStatusLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleStatusChange"
/>
</div>
<div class="flex items-center justify-between last:mt-4 gap-2">
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
</span>
<SelectMenu
:model-value="chatSortFilter"
:options="chatSortOptions"
:label="activeChatSortLabel"
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
@update:model-value="handleSortChange"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,144 @@
<script>
import { mapGetters } from 'vuex';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
export default {
components: {
ConversationHeader,
DashboardAppFrame,
EmptyState,
MessagesView,
},
props: {
inboxId: {
type: [Number, String],
default: '',
required: false,
},
isInboxView: {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
},
data() {
return { activeIndex: 0 };
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
dashboardApps: 'dashboardApps/getRecords',
}),
dashboardAppTabs() {
return [
{
key: 'messages',
index: 0,
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
},
...this.dashboardApps.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`,
index: index + 1,
name: dashboardApp.title,
})),
];
},
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id': {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
'currentChat.id'() {
this.fetchLabels();
this.activeIndex = 0;
},
},
mounted() {
this.fetchLabels();
this.$store.dispatch('dashboardApps/get');
},
methods: {
fetchLabels() {
if (!this.currentChat.id) {
return;
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
},
};
</script>
<template>
<div
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-surface-1 relative"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
>
<ConversationHeader
v-if="currentChat.id"
:chat="currentChat"
:show-back-button="isOnExpandedLayout && !isInboxView"
:class="{
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
}"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="h-10"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
v-for="tab in dashboardAppTabs"
:key="tab.key"
:index="tab.index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
<MessagesView
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<slot />
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content"
:position="index"
:current-chat="currentChat"
/>
</div>
</template>

View File

@@ -0,0 +1,402 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
>
<div
class="relative"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
</h4>
<VoiceCallStatus
v-if="voiceCallData.status"
key="voice-status-row"
:status="voiceCallData.status"
:direction="voiceCallData.direction"
:message-preview-class="messagePreviewClass"
/>
<MessagePreview
v-else-if="lastMessageInChat"
key="message-preview"
:message="lastMessageInChat"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
key="no-messages"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
</div>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>

View File

@@ -0,0 +1,157 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showBackButton: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
accountId: accountId.value,
inboxId,
label,
teamId,
conversationType: conversationTypeMap[name],
customViewId,
});
});
const isHMACVerified = computed(() => {
if (!isAWebWidgetInbox.value) {
return true;
}
return chatMetadata.value.hmac_verified;
});
const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat.meta.sender.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const snoozedDisplayText = computed(() => {
const { snoozed_until: snoozedUntil } = currentChat.value;
if (snoozedUntil) {
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
}
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
});
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
return store.getters['inboxes/getInbox'](inboxId);
});
const hasMultipleInboxes = computed(
() => store.getters['inboxes/getInboxes'].length > 1
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
</script>
<template>
<div
ref="conversationHeader"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 pt-3 pb-2 h-24 xl:h-12"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<BackButton
v-if="showBackButton"
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Avatar
:name="currentContact.name"
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
<div
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden md:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
if (isContactSidebarOpen) {
return 0;
}
return null;
});
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const closeContactPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
</script>
<template>
<div
v-on-click-outside="() => closeContactPanel()"
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
'md:hidden': activeTab !== 0,
},
]"
>
<div class="flex flex-1 overflow-auto">
<ContactPanel
v-show="activeTab === 0"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
show: {
type: Boolean,
default: false,
},
currentChat: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
email: '',
selectedType: '',
isSubmitting: false,
};
},
validations: {
email: {
required,
email,
minLength: minLength(4),
},
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
sentToOtherEmailAddress() {
return this.selectedType === 'other_email_address';
},
isFormValid() {
if (this.selectedType) {
if (this.sentToOtherEmailAddress) {
return !!this.email && !this.v$.email.$error;
}
return true;
}
return false;
},
selectedEmailAddress() {
const { meta } = this.currentChat;
switch (this.selectedType) {
case 'contact':
return meta.sender.email;
case 'assignee':
return meta.assignee.email;
case 'other_email_address':
return this.email;
default:
return '';
}
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
async onSubmit() {
this.isSubmitting = false;
try {
await this.$store.dispatch('sendEmailTranscript', {
email: this.selectedEmailAddress,
conversationId: this.currentChat.id,
});
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
} finally {
this.isSubmitting = false;
}
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onCancel">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('EMAIL_TRANSCRIPT.TITLE')"
:header-content="$t('EMAIL_TRANSCRIPT.DESC')"
/>
<form class="w-full" @submit.prevent="onSubmit">
<div class="w-full">
<div
v-if="currentChat.meta.sender && currentChat.meta.sender.email"
class="flex items-center gap-2"
>
<input
id="contact"
v-model="selectedType"
type="radio"
name="selectedType"
value="contact"
/>
<label for="contact">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<input
id="assignee"
v-model="selectedType"
type="radio"
name="selectedType"
value="assignee"
/>
<label for="assignee">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_AGENT')
}}</label>
</div>
<div class="flex items-center gap-2">
<input
id="other_email_address"
v-model="selectedType"
type="radio"
name="selectedType"
value="other_email_address"
/>
<label for="other_email_address">{{
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_OTHER_EMAIL_ADDRESS')
}}</label>
</div>
<div v-if="sentToOtherEmailAddress" class="w-[50%] mt-1">
<label :class="{ error: v$.email.$error }">
<input
v-model="email"
type="text"
:placeholder="$t('EMAIL_TRANSCRIPT.FORM.EMAIL.PLACEHOLDER')"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('EMAIL_TRANSCRIPT.FORM.EMAIL.ERROR') }}
</span>
</label>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('EMAIL_TRANSCRIPT.CANCEL')"
@click.prevent="onCancel"
/>
<NextButton
type="submit"
:label="$t('EMAIL_TRANSCRIPT.SUBMIT')"
:disabled="!isFormValid"
/>
</div>
</form>
</div>
</woot-modal>
</template>

View File

@@ -0,0 +1,98 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAccount } from 'dashboard/composables/useAccount';
import OnboardingView from '../OnboardingView.vue';
import EmptyStateMessage from './EmptyStateMessage.vue';
export default {
components: {
OnboardingView,
EmptyStateMessage,
},
props: {
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
allConversations: 'getAllConversations',
inboxesList: 'inboxes/getInboxes',
uiFlags: 'inboxes/getUIFlags',
loadingChatList: 'getChatListLoadingStatus',
}),
loadingIndicatorMessage() {
if (this.uiFlags.isFetching) {
return this.$t('CONVERSATION.LOADING_INBOXES');
}
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
},
conversationMissingMessage() {
if (!this.isOnExpandedLayout) {
return this.$t('CONVERSATION.SELECT_A_CONVERSATION');
}
return this.$t('CONVERSATION.404');
},
newInboxURL() {
return this.accountScopedUrl('settings/inboxes/new');
},
emptyClassName() {
if (
!this.inboxesList.length &&
!this.uiFlags.isFetching &&
!this.loadingChatList &&
this.isAdmin
) {
return 'h-full overflow-auto w-full';
}
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full bg-n-surface-1';
},
},
};
</script>
<template>
<div :class="emptyClassName">
<woot-loading-state
v-if="uiFlags.isFetching || loadingChatList"
:message="loadingIndicatorMessage"
/>
<!-- No inboxes attached -->
<div
v-if="!inboxesList.length && !uiFlags.isFetching && !loadingChatList"
class="clearfix mx-auto"
>
<OnboardingView v-if="isAdmin" />
<EmptyStateMessage v-else :message="$t('CONVERSATION.NO_INBOX_AGENT')" />
</div>
<!-- Show empty state images if not loading -->
<div
v-else-if="!uiFlags.isFetching && !loadingChatList"
class="flex flex-col items-center justify-center h-full"
>
<!-- No conversations available -->
<EmptyStateMessage
v-if="!allConversations.length"
:message="$t('CONVERSATION.NO_MESSAGE_1')"
/>
<EmptyStateMessage
v-else-if="allConversations.length && !currentChat.id"
:message="conversationMissingMessage"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script>
import FeaturePlaceholder from './FeaturePlaceholder.vue';
export default {
components: { FeaturePlaceholder },
props: {
message: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="flex flex-col items-center justify-center h-full">
<img
class="m-4 w-32 hidden dark:block"
src="dashboard/assets/images/no-chat-dark.svg"
alt="No Chat dark"
/>
<img
class="m-4 w-32 block dark:hidden"
src="dashboard/assets/images/no-chat.svg"
alt="No Chat"
/>
<span class="text-sm text-n-slate-12 font-medium text-center">
{{ message }}
<br />
</span>
<!-- Cmd bar, keyboard shortcuts placeholder -->
<FeaturePlaceholder />
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script>
import Hotkey from 'dashboard/components/base/Hotkey.vue';
export default {
components: {
Hotkey,
},
data() {
return {
keyShortcuts: [
{
key: 'K',
description: this.$t('CONVERSATION.EMPTY_STATE.CMD_BAR'),
},
{
key: '/',
description: this.$t('CONVERSATION.EMPTY_STATE.KEYBOARD_SHORTCUTS'),
},
],
};
},
};
</script>
<template>
<div class="flex flex-col gap-2 mt-9">
<div
v-for="keyShortcut in keyShortcuts"
:key="keyShortcut.key"
class="flex items-center gap-2"
>
<div class="flex items-center gap-2">
<Hotkey
custom-class="w-8 h-6 text-lg font-medium text-n-slate-12 outline outline-n-container outline-1 bg-n-alpha-3"
>
</Hotkey>
<Hotkey
custom-class="w-8 h-6 text-xs font-medium text-n-slate-12 outline outline-n-container outline-1 bg-n-alpha-3"
>
{{ keyShortcut.key }}
</Hotkey>
</div>
<span class="text-sm font-medium text-center text-n-slate-12">
{{ keyShortcut.description }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script>
export default {
props: {
selectedValue: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
pathPrefix: {
type: String,
required: true,
},
},
emits: ['onChangeFilter'],
data() {
return {
activeValue: this.selectedValue,
};
},
methods: {
onTabChange() {
if (this.type === 'status') {
this.$store.dispatch('setChatStatusFilter', this.activeValue);
} else {
this.$store.dispatch('setChatSortFilter', this.activeValue);
}
this.$emit('onChangeFilter', this.activeValue, this.type);
},
},
};
</script>
<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"
@change="onTabChange()"
>
<option v-for="value in items" :key="value" :value="value">
{{ $t(`${pathPrefix}.${value}.TEXT`) }}
</option>
</select>
</template>

View File

@@ -0,0 +1,109 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
props: {
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.OUTGOING;
},
isMessageAnActivity() {
const { message_type: messageType } = this.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
},
isMessagePrivate() {
const { private: isPrivate } = this.message;
return isPrivate;
},
parsedLastMessage() {
const { content_attributes: contentAttributes } = this.message;
const { email: { subject } = {} } = contentAttributes || {};
return this.getPlainText(subject || this.message.content);
},
lastMessageFileType() {
const [{ file_type: fileType } = {}] = this.message.attachments;
return fileType;
},
attachmentIcon() {
return ATTACHMENT_ICONS[this.lastMessageFileType];
},
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isMessageSticker() {
return this.message && this.message.content_type === 'sticker';
},
},
};
</script>
<template>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
<template v-if="showMessageType">
<fluent-icon
v-if="isMessagePrivate"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="lock-closed"
/>
<fluent-icon
v-else-if="messageByAgent"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="arrow-reply"
/>
<fluent-icon
v-else-if="isMessageAnActivity"
size="16"
class="-mt-0.5 align-middle text-n-slate-11 inline-block"
icon="info"
/>
</template>
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
<fluent-icon
v-if="attachmentIcon && showMessageType"
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-11"
:icon="attachmentIcon"
/>
{{ $t(`${attachmentMessageContent}`) }}
</span>
<span v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</span>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
const router = useRouter();
const openProfileSettings = () => {
return router.push({ name: 'profile_settings_index' });
};
</script>
<template>
<div
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<Button
link
:label="$t('CONVERSATION.FOOTER.CLICK_HERE')"
@click="openProfileSettings"
/>
</p>
</div>
</template>

View File

@@ -0,0 +1,561 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
// utils
import { emitter } from 'shared/helpers/mitt';
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
Spinner,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
const {
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
getLabelSuggestions,
} = useLabelSuggestions();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isPopOutReplyBox,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
conversationPanelRef,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.captainTasksEnabled &&
this.isLabelSuggestionFeatureEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const [i18nKey, params] = getTypingUsersText(userList);
return this.$t(i18nKey, params);
}
return '';
},
getMessages() {
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (this.isAPIInbox) {
const { additional_attributes: additionalAttributes = {} } = this.inbox;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (this.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (this.isATiktokChannel) {
return this.$t('CONVERSATION.48_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages =
this.conversationPanel.querySelectorAll('.message--unread');
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
if (
scrollTop < 100 &&
!this.isLoadingPrevious &&
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
},
};
</script>
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li
class="min-h-[4rem] flex flex-shrink-0 flex-grow-0 items-center flex-auto justify-center max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0"
>
<Spinner v-if="shouldShowSpinner" class="text-n-brand" />
</li>
</transition>
</template>
<template #unreadBadge>
<li
v-show="unreadMessageCount != 0"
class="list-none flex justify-center items-center"
>
<span
class="shadow-lg rounded-full bg-n-brand text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5"
>
{{ unreadMessageLabel }}
</span>
</li>
</template>
<template #after>
<ConversationLabelSuggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</template>
</MessageList>
<div
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7"
>
<div
class="flex py-2 pr-4 pl-5 shadow-md rounded-full bg-white dark:bg-n-solid-3 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
>
{{ typingUserNames }}
<img
class="w-6 ltr:ml-2 rtl:mr-2"
src="assets/images/typing.gif"
alt="Someone is typing"
/>
</div>
</div>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup>
import { computed, onUnmounted } from 'vue';
import { useToggle } from '@vueuse/core';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import {
CMD_MUTE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
// No props needed as we're getting currentChat from the store directly
const store = useStore();
const { t } = useI18n();
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
const [showActionsDropdown, toggleDropdown] = useToggle(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const actionMenuItems = computed(() => {
const items = [];
if (!currentChat.value.muted) {
items.push({
icon: 'i-lucide-volume-off',
label: t('CONTACT_PANEL.MUTE_CONTACT'),
action: 'mute',
value: 'mute',
});
} else {
items.push({
icon: 'i-lucide-volume-1',
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
action: 'unmute',
value: 'unmute',
});
}
items.push({
icon: 'i-lucide-share',
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
action: 'send_transcript',
value: 'send_transcript',
});
return items;
});
const handleActionClick = ({ action }) => {
toggleDropdown(false);
if (action === 'mute') {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
} else if (action === 'unmute') {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
} else if (action === 'send_transcript') {
toggleEmailModal();
}
};
// These functions are needed for the event listeners
const mute = () => {
store.dispatch('muteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
};
const unmute = () => {
store.dispatch('unmuteConversation', currentChat.value.id);
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
};
emitter.on(CMD_MUTE_CONVERSATION, mute);
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
onUnmounted(() => {
emitter.off(CMD_MUTE_CONVERSATION, mute);
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
});
</script>
<template>
<div class="relative flex items-center gap-2 actions--container">
<ResolveAction
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<ButtonV4
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-more-vertical"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="actionMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleActionClick"
/>
</div>
<EmailTranscriptModal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
:current-chat="currentChat"
@cancel="toggleEmailModal"
/>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
defineProps({
imageSrc: {
type: String,
default: '',
},
imageAlt: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
to: {
type: String,
default: '',
},
linkText: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="h-full w-full bg-n-surface-2 border border-n-weak rounded-lg p-4 flex flex-col"
>
<div class="flex-1 flex items-center justify-center">
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
</div>
<div class="mt-auto">
<p
class="text-base text-n-slate-12 font-interDisplay font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
<p class="text-n-slate-11 text-sm">
{{ description }}
</p>
<router-link
v-if="to"
:to="{ name: to }"
class="no-underline text-n-brand text-sm font-medium"
>
<span>{{ linkText }}</span>
<span class="ml-2">{{ `` }}</span>
</router-link>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const currentUser = computed(() => getters.getCurrentUser.value);
const greetingMessage = computed(() => {
const hours = new Date().getHours();
let translationKey;
if (hours < 12) {
translationKey = 'ONBOARDING.GREETING_MORNING';
} else if (hours < 18) {
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
} else {
translationKey = 'ONBOARDING.GREETING_EVENING';
}
return t(translationKey, {
name: currentUser.value.name,
installationName: globalConfig.value.installationName,
});
});
</script>
<template>
<div
class="min-h-screen lg:max-w-5xl max-w-4xl mx-auto grid grid-cols-2 grid-rows-[auto_1fr_1fr] auto-rows-min gap-4 p-8 w-full font-inter overflow-auto"
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-n-slate-12 font-interDisplay tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
<p class="text-n-slate-11 max-w-2xl text-base">
{{
$t('ONBOARDING.DESCRIPTION', {
installationName: globalConfig.installationName,
})
}}
</p>
</div>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
image-alt="Omnichannel"
to="settings_inbox_new"
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/teams.png"
image-alt="Teams"
to="settings_teams_new"
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/canned-responses.png"
image-alt="Canned responses"
to="canned_list"
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
/>
<OnboardingFeatureCard
image-src="/dashboard/images/onboarding/labels.png"
image-alt="Labels"
to="labels_list"
:title="$t('ONBOARDING.LABELS.TITLE')"
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
/>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
quotedEmailText: {
type: String,
required: true,
},
previewText: {
type: String,
required: true,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const formattedQuotedEmailText = computed(() => {
if (!props.quotedEmailText) {
return '';
}
return formatMessage(props.quotedEmailText, false, false, true);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<div class="mt-2">
<div
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
>
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
<NextButton
v-tooltip="
isExpanded
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
"
ghost
slate
xs
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
@click="toggleExpand"
/>
<NextButton
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
ghost
slate
xs
icon="i-lucide-x"
@click="emit('toggle')"
/>
</div>
<div
v-dompurify-html="formattedQuotedEmailText"
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
:class="{
'line-clamp-1': !isExpanded,
'max-h-60 overflow-y-auto': isExpanded,
}"
:title="previewText"
@click="toggleExpand"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import Banner from 'dashboard/components/ui/Banner.vue';
const props = defineProps({
message: {
type: String,
default: '',
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', {
conversationId: currentChat.value?.id,
assignee: agent,
});
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
});
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
);
const showSelfAssignBanner = computed(() => {
return (
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
});
const selfAssignConversation = async () => {
const { avatar_url, ...rest } = currentUser.value || {};
assignedAgent.value = { ...rest, thumbnail: avatar_url };
};
const needsAssignmentToCurrentUser = computed(() => {
return isUnassigned.value || isAssignedToOtherAgent.value;
});
const onClickSelfAssign = async () => {
try {
await selfAssignConversation();
useAlert(t('CONVERSATION.CHANGE_AGENT'));
} catch (error) {
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
}
};
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
});
};
const onClickBotHandoff = async () => {
try {
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
await selfAssignConversation();
}
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
}
};
</script>
<template>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<Banner
v-if="showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
has-action-button
:action-button-label="botHandoffActionLabel"
@primary-action="onClickBotHandoff"
/>
</template>

View File

@@ -0,0 +1,179 @@
<script>
import { validEmailsByComma } from './helpers/emailHeadHelper';
import { useVuelidate } from '@vuelidate/core';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
export default {
components: {
ButtonV4,
},
props: {
ccEmails: {
type: String,
default: '',
},
bccEmails: {
type: String,
default: '',
},
toEmails: {
type: String,
default: '',
},
},
emits: ['update:bccEmails', 'update:ccEmails', 'update:toEmails'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
showBcc: false,
ccEmailsVal: '',
bccEmailsVal: '',
toEmailsVal: '',
};
},
watch: {
bccEmails(newVal) {
if (newVal !== this.bccEmailsVal) {
this.bccEmailsVal = newVal;
}
},
ccEmails(newVal) {
if (newVal !== this.ccEmailsVal) {
this.ccEmailsVal = newVal;
}
},
toEmails(newVal) {
if (newVal !== this.toEmailsVal) {
this.toEmailsVal = newVal;
}
},
},
mounted() {
this.ccEmailsVal = this.ccEmails;
this.bccEmailsVal = this.bccEmails;
this.toEmailsVal = this.toEmails;
},
validations: {
ccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
bccEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
toEmailsVal: {
hasValidEmails(value) {
return validEmailsByComma(value);
},
},
},
methods: {
handleAddBcc() {
this.showBcc = true;
},
onBlur() {
this.v$.$touch();
this.$emit('update:bccEmails', this.bccEmailsVal);
this.$emit('update:ccEmails', this.ccEmailsVal);
this.$emit('update:toEmails', this.toEmailsVal);
},
},
};
</script>
<template>
<div>
<div v-if="toEmails">
<div class="input-group small" :class="{ error: v$.toEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.TO') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.toEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
/>
</div>
</div>
</div>
<div class="input-group-wrap">
<div class="input-group small" :class="{ error: v$.ccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.LABEL') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.ccEmailsVal.$model"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
type="text"
:class="{ error: v$.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
/>
</div>
<ButtonV4
v-if="!showBcc"
:label="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.ADD_BCC')"
ghost
xs
primary
@click="handleAddBcc"
/>
</div>
<span v-if="v$.ccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.ERROR') }}
</span>
</div>
<div v-if="showBcc" class="input-group-wrap">
<div class="input-group small" :class="{ error: v$.bccEmailsVal.$error }">
<label class="input-group-label">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.LABEL') }}
</label>
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.bccEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
"
@blur="onBlur"
/>
</div>
</div>
<span v-if="v$.bccEmailsVal.$error" class="message">
{{ $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.ERROR') }}
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.input-group-wrap .message {
@apply text-sm text-n-ruby-8;
}
.input-group {
@apply border-b border-solid border-n-weak my-1 flex items-center gap-2;
.input-group-label {
@apply border-transparent bg-transparent text-xs font-semibold pl-0;
}
}
.input-group.error {
@apply border-n-ruby-8;
.input-group-label {
@apply text-n-ruby-8;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
const emit = defineEmits(['dismiss']);
</script>
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
{{ $t('CONVERSATION.REPLYBOX.REPLYING_TO') }}
<MessagePreview
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
class="inline"
/>
</div>
<Button
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
ghost
xs
slate
icon="i-lucide-x"
@click.stop="emit('dismiss')"
/>
</div>
</template>
<style lang="scss">
// TODO: Remove this
// override for dashboard/assets/scss/widgets/_reply-box.scss
.reply-editor {
.icon {
margin-right: 0px !important;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { format } from 'date-fns';
import { useI18n } from 'vue-i18n';
const props = defineProps({
order: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const formatDate = dateString => {
return format(new Date(dateString), 'MMM d, yyyy');
};
const formatCurrency = (amount, currency) => {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: currency || 'USD',
}).format(amount);
};
const getStatusClass = status => {
const classes = {
paid: 'bg-n-teal-5 text-n-teal-12',
};
return classes[status] || 'bg-n-solid-3 text-n-slate-12';
};
const getStatusI18nKey = (type, status = '') => {
return `CONVERSATION_SIDEBAR.SHOPIFY.${type.toUpperCase()}_STATUS.${status.toUpperCase()}`;
};
const fulfillmentStatus = computed(() => {
const { fulfillment_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FULFILLMENT', status));
});
const financialStatus = computed(() => {
const { financial_status: status } = props.order;
if (!status) {
return '';
}
return t(getStatusI18nKey('FINANCIAL', status));
});
const getFulfillmentClass = status => {
const classes = {
fulfilled: 'text-n-teal-9',
partial: 'text-n-amber-9',
unfulfilled: 'text-n-ruby-9',
};
return classes[status] || 'text-n-slate-11';
};
</script>
<template>
<div
class="py-3 border-b border-n-weak last:border-b-0 flex flex-col gap-1.5"
>
<div class="flex justify-between items-center">
<div class="font-medium flex">
<a
:href="order.admin_url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline text-n-slate-12 cursor-pointer truncate"
>
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.ORDER_ID', { id: order.id }) }}
<i class="i-lucide-external-link pl-5" />
</a>
</div>
<div
:class="getStatusClass(order.financial_status)"
class="text-xs px-2 py-1 rounded capitalize truncate"
:title="financialStatus"
>
{{ financialStatus }}
</div>
</div>
<div class="text-sm text-n-slate-12">
<span class="text-n-slate-11 border-r border-n-weak pr-2">
{{ formatDate(order.created_at) }}
</span>
<span class="text-n-slate-11 pl-2">
{{ formatCurrency(order.total_price, order.currency) }}
</span>
</div>
<div v-if="fulfillmentStatus">
<span
:class="getFulfillmentClass(order.fulfillment_status)"
class="capitalize font-medium"
:title="fulfillmentStatus"
>
{{ fulfillmentStatus }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useFunctionGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ShopifyAPI from '../../../api/integrations/shopify';
import ShopifyOrderItem from './ShopifyOrderItem.vue';
const props = defineProps({
contactId: {
type: [Number, String],
required: true,
},
});
const contact = useFunctionGetter('contacts/getContact', props.contactId);
const hasSearchableInfo = computed(
() => !!contact.value?.email || !!contact.value?.phone_number
);
const orders = ref([]);
const loading = ref(true);
const error = ref('');
const fetchOrders = async () => {
try {
loading.value = true;
const response = await ShopifyAPI.getOrders(props.contactId);
orders.value = response.data.orders;
} catch (e) {
error.value =
e.response?.data?.error || 'CONVERSATION_SIDEBAR.SHOPIFY.ERROR';
} finally {
loading.value = false;
}
};
watch(
() => props.contactId,
() => {
if (hasSearchableInfo.value) {
fetchOrders();
}
},
{ immediate: true }
);
</script>
<template>
<div class="px-4 py-2 text-n-slate-12">
<div v-if="!hasSearchableInfo" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else-if="loading" class="flex justify-center items-center p-4">
<Spinner size="32" class="text-n-brand" />
</div>
<div v-else-if="error" class="text-center text-n-ruby-12">
{{ error }}
</div>
<div v-else-if="!orders.length" class="text-center text-n-slate-12">
{{ $t('CONVERSATION_SIDEBAR.SHOPIFY.NO_SHOPIFY_ORDERS') }}
</div>
<div v-else>
<ShopifyOrderItem
v-for="order in orders"
:key="order.id"
:order="order"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import { ref, computed, watch, nextTick } from 'vue';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useI18n } from 'vue-i18n';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectAgent']);
const { t } = useI18n();
const getters = useStoreGetters();
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const teams = useMapGetter('teams/getTeams');
const tagAgentsRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
const buildItems = (list, type, infoKey) =>
list
.map(item => ({
...item,
type,
displayName: item.name,
displayInfo: item[infoKey],
}))
.filter(item =>
search ? item.displayName.toLowerCase().includes(search) : true
);
const categories = [
{
title: t('CONVERSATION.MENTION.AGENTS'),
data: buildItems(agents.value, 'user', 'email'),
},
{
title: t('CONVERSATION.MENTION.TEAMS'),
data: buildItems(teams.value, 'team', 'description'),
},
];
return categories.flatMap(({ title, data }) =>
data.length
? [
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
...data,
]
: []
);
});
const selectableItems = computed(() => {
return items.value.filter(item => item.type !== 'header');
});
const getSelectableIndex = item => {
return selectableItems.value.findIndex(
selectableItem =>
selectableItem.type === item.type && selectableItem.id === item.id
);
};
const adjustScroll = () => {
nextTick(() => {
if (tagAgentsRef.value) {
const selectedElement = tagAgentsRef.value.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'auto',
});
}
}
});
};
const onSelect = () => {
emit('selectAgent', selectableItems.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: selectableItems,
onSelect,
adjustScroll,
selectedIndex,
});
watch(selectableItems, newListOfAgents => {
if (newListOfAgents.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
const onHover = index => {
selectedIndex.value = index;
};
const onAgentSelect = index => {
selectedIndex.value = index;
onSelect();
};
</script>
<template>
<div>
<ul
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
role="listbox"
>
<li
v-for="item in items"
:id="
item.type === 'header'
? undefined
: `mention-item-${getSelectableIndex(item)}`
"
:key="`${item.type}-${item.id}`"
>
<!-- Section Header -->
<div
v-if="item.type === 'header'"
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
>
{{ item.title }}
</div>
<!-- Selectable Item -->
<div
v-else
:class="{
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
}"
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
role="option"
@click="onAgentSelect(getSelectableIndex(item))"
@mouseover="onHover(getSelectableIndex(item))"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar
:src="item.thumbnail"
:name="item.displayName"
rounded-full
/>
</div>
<div
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
>
<h5
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
:class="{
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayName }}
</h5>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayInfo }}
</div>
</div>
</div>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { ref, computed, watch } from 'vue';
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMapGetter } from 'dashboard/composables/store.js';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectTool']);
const tools = useMapGetter('captainTools/getRecords');
const selectedIndex = ref(0);
const filteredTools = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
});
const adjustScroll = () => {};
const onSelect = idx => {
if (idx) selectedIndex.value = idx;
emit('selectTool', filteredTools.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: filteredTools,
onSelect,
adjustScroll,
selectedIndex,
});
watch(filteredTools, newListOfTools => {
if (newListOfTools.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
</script>
<template>
<ToolsDropdown
v-if="filteredTools.length"
:items="filteredTools"
:selected-index="selectedIndex"
class="bottom-20"
@select="onSelect"
/>
<template v-else />
</template>

View File

@@ -0,0 +1,78 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
import MentionBox from '../mentions/MentionBox.vue';
export default {
components: { MentionBox },
props: {
searchKey: {
type: String,
default: '',
},
},
emits: ['selectVariable'],
computed: {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
sanitizedSearchKey() {
return sanitizeVariableSearchKey(this.searchKey);
},
items() {
return [
...this.standardAttributeVariables,
...this.customAttributeVariables,
];
},
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.sanitizedSearchKey) ||
variable.key.includes(this.sanitizedSearchKey)
);
}).map(variable => ({
label: variable.key,
key: variable.key,
description: variable.label,
}));
},
customAttributeVariables() {
return this.customAttributes.map(attribute => {
const attributePrefix =
attribute.attribute_model === 'conversation_attribute'
? 'conversation'
: 'contact';
return {
label: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
key: `${attributePrefix}.custom_attribute.${attribute.attribute_key}`,
description: attribute.attribute_description,
};
});
},
},
methods: {
handleVariableClick(item = {}) {
this.$emit('selectVariable', item.key);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="variable"
:items="items"
@mention-select="handleVariableClick"
/>
</template>
<style scoped>
.variable--list-label {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
messagePreviewClass: { type: [String, Array, Object], default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[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 COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === 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 iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<Icon
class="inline-block -mt-0.5 align-middle size-4"
:icon="iconName"
:class="statusColor"
/>
<span class="mx-1" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script>
import TemplatesPicker from './TemplatesPicker.vue';
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
export default {
components: {
TemplatesPicker,
WhatsAppTemplateReply,
},
props: {
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['onSend', 'cancel', 'update:show'],
data() {
return {
selectedWaTemplate: null,
};
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
modalHeaderContent() {
return this.selectedWaTemplate
? this.$t('WHATSAPP_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: this.selectedWaTemplate.name,
})
: this.$t('WHATSAPP_TEMPLATES.MODAL.SUBTITLE');
},
},
methods: {
pickTemplate(template) {
this.selectedWaTemplate = template;
},
onResetTemplate() {
this.selectedWaTemplate = null;
},
onSendMessage(message) {
this.$emit('onSend', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('WHATSAPP_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="row modal-content">
<TemplatesPicker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<WhatsAppTemplateReply
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
/>
</div>
</woot-modal>
</template>
<style scoped>
.modal-content {
padding: 1.5625rem 2rem;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import {
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const whatsAppTemplateMessages = useFunctionGetter(
'inboxes/getFilteredWhatsAppTemplates',
toRef(props, 'inboxId')
);
const filteredTemplateMessages = computed(() =>
whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const getTemplateBody = template => {
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
};
const getTemplateHeader = template => {
return findComponentByType(template, COMPONENT_TYPES.HEADER);
};
const getTemplateFooter = template => {
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
};
const getTemplateButtons = template => {
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
};
const hasMediaContent = template => {
const header = getTemplateHeader(template);
return header && MEDIA_FORMATS.includes(header.format);
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.name }}
</p>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ template.language }}
</span>
</div>
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) ||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
}}
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p>
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleResetTemplate = () => {
emit('resetTemplate');
};
</script>
<template>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
:disabled="disabled"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
</div>
</template>

View File

@@ -0,0 +1,174 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from '../../FilterInput/FilterOperatorTypes';
const filterTypes = [
{
attributeKey: 'status',
attributeI18nKey: 'STATUS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
attributeI18nKey: 'ASSIGNEE_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'priority',
attributeI18nKey: 'PRIORITY',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'inbox_id',
attributeI18nKey: 'INBOX_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
attributeI18nKey: 'TEAM_NAME',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
inputType: 'plain_text',
dataType: 'Number',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
attributeI18nKey: 'CAMPAIGN_NAME',
inputType: 'search_select',
dataType: 'Number',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'browser_language',
attributeI18nKey: 'BROWSER_LANGUAGE',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
attributeI18nKey: 'LAST_ACTIVITY',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
{
name: 'Standard Filters',
i18nGroup: 'STANDARD_FILTERS',
attributes: [
{
key: 'status',
i18nKey: 'STATUS',
},
{
key: 'assignee_id',
i18nKey: 'ASSIGNEE_NAME',
},
{
key: 'inbox_id',
i18nKey: 'INBOX_NAME',
},
{
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
},
{
key: 'campaign_id',
i18nKey: 'CAMPAIGN_NAME',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
},
{
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
],
},
{
name: 'Additional Filters',
i18nGroup: 'ADDITIONAL_FILTERS',
attributes: [
{
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
},
],
},
];
export default filterTypes;

View File

@@ -0,0 +1,751 @@
const languages = [
{
name: 'Abkhazian',
id: 'ab',
},
{
name: 'Afar',
id: 'aa',
},
{
name: 'Afrikaans',
id: 'af',
},
{
name: 'Akan',
id: 'ak',
},
{
name: 'Albanian',
id: 'sq',
},
{
name: 'Amharic',
id: 'am',
},
{
name: 'Arabic',
id: 'ar',
},
{
name: 'Aragonese',
id: 'an',
},
{
name: 'Armenian',
id: 'hy',
},
{
name: 'Assamese',
id: 'as',
},
{
name: 'Avaric',
id: 'av',
},
{
name: 'Avestan',
id: 'ae',
},
{
name: 'Aymara',
id: 'ay',
},
{
name: 'Azerbaijani',
id: 'az',
},
{
name: 'Bambara',
id: 'bm',
},
{
name: 'Bashkir',
id: 'ba',
},
{
name: 'Basque',
id: 'eu',
},
{
name: 'Belarusian',
id: 'be',
},
{
name: 'Bengali',
id: 'bn',
},
{
name: 'Bislama',
id: 'bi',
},
{
name: 'Bosnian',
id: 'bs',
},
{
name: 'Breton',
id: 'br',
},
{
name: 'Bulgarian',
id: 'bg',
},
{
name: 'Burmese',
id: 'my',
},
{
name: 'Catalan',
id: 'ca',
},
{
name: 'Chamorro',
id: 'ch',
},
{
name: 'Chechen',
id: 'ce',
},
{
name: 'Chichewa',
id: 'ny',
},
{
name: 'Chinese',
id: 'zh',
},
{
name: 'Church Slavonic',
id: 'cu',
},
{
name: 'Chuvash',
id: 'cv',
},
{
name: 'Cornish',
id: 'kw',
},
{
name: 'Corsican',
id: 'co',
},
{
name: 'Cree',
id: 'cr',
},
{
name: 'Croatian',
id: 'hr',
},
{
name: 'Czech',
id: 'cs',
},
{
name: 'Danish',
id: 'da',
},
{
name: 'Divehi',
id: 'dv',
},
{
name: 'Dutch',
id: 'nl',
},
{
name: 'Dzongkha',
id: 'dz',
},
{
name: 'English',
id: 'en',
},
{
name: 'Esperanto',
id: 'eo',
},
{
name: 'Estonian',
id: 'et',
},
{
name: 'Ewe',
id: 'ee',
},
{
name: 'Faroese',
id: 'fo',
},
{
name: 'Fijian',
id: 'fj',
},
{
name: 'Finnish',
id: 'fi',
},
{
name: 'French',
id: 'fr',
},
{
name: 'Western Frisian',
id: 'fy',
},
{
name: 'Fulah',
id: 'ff',
},
{
name: 'Gaelic',
id: 'gd',
},
{
name: 'Galician',
id: 'gl',
},
{
name: 'Ganda',
id: 'lg',
},
{
name: 'Georgian',
id: 'ka',
},
{
name: 'German',
id: 'de',
},
{
name: 'Greek',
id: 'el',
},
{
name: 'Kalaallisut',
id: 'kl',
},
{
name: 'Guarani',
id: 'gn',
},
{
name: 'Gujarati',
id: 'gu',
},
{
name: 'Haitian',
id: 'ht',
},
{
name: 'Hausa',
id: 'ha',
},
{
name: 'Hebrew',
id: 'he',
},
{
name: 'Herero',
id: 'hz',
},
{
name: 'Hindi',
id: 'hi',
},
{
name: 'Hiri Motu',
id: 'ho',
},
{
name: 'Hungarian',
id: 'hu',
},
{
name: 'Icelandic',
id: 'is',
},
{
name: 'Ido',
id: 'io',
},
{
name: 'Igbo',
id: 'ig',
},
{
name: 'Indonesian',
id: 'id',
},
{
name: 'Interlingua',
id: 'ia',
},
{
name: 'Interlingue',
id: 'ie',
},
{
name: 'Inuktitut',
id: 'iu',
},
{
name: 'Inupiaq',
id: 'ik',
},
{
name: 'Irish',
id: 'ga',
},
{
name: 'Italian',
id: 'it',
},
{
name: 'Japanese',
id: 'ja',
},
{
name: 'Javanese',
id: 'jv',
},
{
name: 'Kannada',
id: 'kn',
},
{
name: 'Kanuri',
id: 'kr',
},
{
name: 'Kashmiri',
id: 'ks',
},
{
name: 'Kazakh',
id: 'kk',
},
{
name: 'Central Khmer',
id: 'km',
},
{
name: 'Kikuyu',
id: 'ki',
},
{
name: 'Kinyarwanda',
id: 'rw',
},
{
name: 'Kirghiz',
id: 'ky',
},
{
name: 'Komi',
id: 'kv',
},
{
name: 'Kongo',
id: 'kg',
},
{
name: 'Korean',
id: 'ko',
},
{
name: 'Kuanyama',
id: 'kj',
},
{
name: 'Kurdish',
id: 'ku',
},
{
name: 'Lao',
id: 'lo',
},
{
name: 'Latin',
id: 'la',
},
{
name: 'Latvian',
id: 'lv',
},
{
name: 'Limburgan',
id: 'li',
},
{
name: 'Lingala',
id: 'ln',
},
{
name: 'Lithuanian',
id: 'lt',
},
{
name: 'Luba-Katanga',
id: 'lu',
},
{
name: 'Luxembourgish',
id: 'lb',
},
{
name: 'Macedonian',
id: 'mk',
},
{
name: 'Malagasy',
id: 'mg',
},
{
name: 'Malay',
id: 'ms',
},
{
name: 'Malayalam',
id: 'ml',
},
{
name: 'Maltese',
id: 'mt',
},
{
name: 'Manx',
id: 'gv',
},
{
name: 'Maori',
id: 'mi',
},
{
name: 'Marathi',
id: 'mr',
},
{
name: 'Marshallese',
id: 'mh',
},
{
name: 'Mongolian',
id: 'mn',
},
{
name: 'Nauru',
id: 'na',
},
{
name: 'Navajo',
id: 'nv',
},
{
name: 'North Ndebele',
id: 'nd',
},
{
name: 'South Ndebele',
id: 'nr',
},
{
name: 'Ndonga',
id: 'ng',
},
{
name: 'Nepali',
id: 'ne',
},
{
name: 'Norwegian',
id: 'no',
},
{
name: 'Norwegian Bokmål',
id: 'nb',
},
{
name: 'Norwegian Nynorsk',
id: 'nn',
},
{
name: 'Sichuan Yi',
id: 'ii',
},
{
name: 'Occitan',
id: 'oc',
},
{
name: 'Ojibwa',
id: 'oj',
},
{
name: 'Oriya',
id: 'or',
},
{
name: 'Oromo',
id: 'om',
},
{
name: 'Ossetian',
id: 'os',
},
{
name: 'Pali',
id: 'pi',
},
{
name: 'Pashto, Pushto',
id: 'ps',
},
{
name: 'Persian',
id: 'fa',
},
{
name: 'Polish',
id: 'pl',
},
{
name: 'Portuguese',
id: 'pt',
},
{
name: 'Portuguese (Brazil)',
id: 'pt_BR',
},
{
name: 'Punjabi',
id: 'pa',
},
{
name: 'Quechua',
id: 'qu',
},
{
name: 'Romanian',
id: 'ro',
},
{
name: 'Romansh',
id: 'rm',
},
{
name: 'Rundi',
id: 'rn',
},
{
name: 'Russian',
id: 'ru',
},
{
name: 'Northern Sami',
id: 'se',
},
{
name: 'Samoan',
id: 'sm',
},
{
name: 'Sango',
id: 'sg',
},
{
name: 'Sanskrit',
id: 'sa',
},
{
name: 'Sardinian',
id: 'sc',
},
{
name: 'Serbian',
id: 'sr',
},
{
name: 'Shona',
id: 'sn',
},
{
name: 'Sindhi',
id: 'sd',
},
{
name: 'Sinhala',
id: 'si',
},
{
name: 'Slovak',
id: 'sk',
},
{
name: 'Slovenian',
id: 'sl',
},
{
name: 'Somali',
id: 'so',
},
{
name: 'Southern Sotho',
id: 'st',
},
{
name: 'Spanish',
id: 'es',
},
{
name: 'Sundanese',
id: 'su',
},
{
name: 'Swahili',
id: 'sw',
},
{
name: 'Swati',
id: 'ss',
},
{
name: 'Swedish',
id: 'sv',
},
{
name: 'Tagalog',
id: 'tl',
},
{
name: 'Tahitian',
id: 'ty',
},
{
name: 'Tajik',
id: 'tg',
},
{
name: 'Tamil',
id: 'ta',
},
{
name: 'Tatar',
id: 'tt',
},
{
name: 'Telugu',
id: 'te',
},
{
name: 'Thai',
id: 'th',
},
{
name: 'Tibetan',
id: 'bo',
},
{
name: 'Tigrinya',
id: 'ti',
},
{
name: 'Tonga',
id: 'to',
},
{
name: 'Tsonga',
id: 'ts',
},
{
name: 'Tswana',
id: 'tn',
},
{
name: 'Turkish',
id: 'tr',
},
{
name: 'Turkmen',
id: 'tk',
},
{
name: 'Twi',
id: 'tw',
},
{
name: 'Uighur',
id: 'ug',
},
{
name: 'Ukrainian',
id: 'uk',
},
{
name: 'Urdu',
id: 'ur',
},
{
name: 'Uzbek',
id: 'uz',
},
{
name: 'Venda',
id: 've',
},
{
name: 'Vietnamese',
id: 'vi',
},
{
name: 'Volapük',
id: 'vo',
},
{
name: 'Walloon',
id: 'wa',
},
{
name: 'Welsh',
id: 'cy',
},
{
name: 'Wolof',
id: 'wo',
},
{
name: 'Xhosa',
id: 'xh',
},
{
name: 'Yiddish',
id: 'yi',
},
{
name: 'Yoruba',
id: 'yo',
},
{
name: 'Zhuang, Chuang',
id: 'za',
},
{
name: 'Zulu',
id: 'zu',
},
];
export const getLanguageName = (languageCode = '') => {
const languageObj =
languages.find(language => language.id === languageCode) || {};
return languageObj.name || '';
};
export const getLanguageDirection = (languageCode = '') => {
const rtlLanguageIds = ['ar', 'as', 'fa', 'he', 'ku', 'ur'];
return rtlLanguageIds.includes(languageCode);
};
export default languages;

View File

@@ -0,0 +1,9 @@
import defaultFilters from '../index';
import { filterAttributeGroups } from '../index';
describe('#filterItems', () => {
it('Matches the correct filterItems', () => {
expect(defaultFilters).toMatchObject(defaultFilters);
expect(filterAttributeGroups).toMatchObject(filterAttributeGroups);
});
});

View File

@@ -0,0 +1,17 @@
import { getLanguageName, getLanguageDirection } from '../languages';
describe('#getLanguageName', () => {
it('Returns correct language name', () => {
expect(getLanguageName('es')).toEqual('Spanish');
expect(getLanguageName()).toEqual('');
expect(getLanguageName('rrr')).toEqual('');
expect(getLanguageName('')).toEqual('');
});
});
describe('#getLanguageDirection', () => {
it('Returns correct language direction', () => {
expect(getLanguageDirection('es')).toEqual(false);
expect(getLanguageDirection('ar')).toEqual(true);
});
});

View File

@@ -0,0 +1,358 @@
<script setup>
import { ref, computed, onMounted, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const { t } = useI18n();
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
IG_REEL: 'ig_reel',
AUDIO: 'audio',
};
const isDownloading = ref(false);
const activeAttachment = ref({});
const activeFileType = ref('');
const activeImageIndex = ref(
props.allAttachments.findIndex(
attachment => attachment.message_id === props.attachment.message_id
) || 0
);
const imageRef = useTemplateRef('imageRef');
const {
imageWrapperStyle,
imageStyle,
onRotate,
activeImageRotation,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
} = useImageZoom(imageRef);
const currentUser = computed(() => getters.getCurrentUser.value);
const hasMoreThanOneAttachment = computed(
() => props.allAttachments.length > 1
);
const readableTime = computed(() => {
const { created_at: createdAt } = activeAttachment.value;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
});
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(() =>
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
activeFileType.value
)
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
);
const senderDetails = computed(() => {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
return {
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl.split('/').pop();
return fileName ? decodeURIComponent(fileName) : '';
});
const onClose = () => emit('close');
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) return;
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
resetZoomAndRotation();
};
const onClickDownload = async () => {
const { file_type: type, data_url: url, extension } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
try {
isDownloading.value = true;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
};
const keyboardEvents = {
Escape: { action: onClose },
ArrowLeft: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value - 1],
activeImageIndex.value - 1
);
},
},
ArrowRight: {
action: () => {
onClickChangeAttachment(
props.allAttachments[activeImageIndex.value + 1],
activeImageIndex.value + 1
);
},
},
};
useKeyboardEvents(keyboardEvents);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
});
</script>
<template>
<TeleportWithDirection to="body">
<woot-modal
v-model:show="show"
full-width
:show-close-button="false"
:on-close="onClose"
>
<div
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
@click="onClose"
>
<header
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
@click.stop
>
<div
v-if="senderDetails"
class="flex items-center min-w-[15rem] shrink-0"
>
<Avatar
v-if="senderDetails.avatar"
:name="senderDetails.name"
:src="senderDetails.avatar"
:size="40"
rounded-full
class="flex-shrink-0"
/>
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
<h3 class="text-base leading-5 m-0 font-medium">
<span
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ senderDetails.name }}
</span>
</h3>
<span
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
>
{{ readableTime }}
</span>
</div>
</div>
<div
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
<div class="flex items-center gap-2 ml-2 shrink-0">
<NextButton
v-if="isImage"
icon="i-lucide-zoom-in"
slate
ghost
@click="onZoom(0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-zoom-out"
slate
ghost
@click="onZoom(-0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-ccw"
slate
ghost
@click="onRotate('counter-clockwise')"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-cw"
slate
ghost
@click="onRotate('clockwise')"
/>
<NextButton
icon="i-lucide-download"
slate
ghost
:is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload"
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</header>
<main class="flex items-stretch flex-1 h-full overflow-hidden">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="ltr:i-lucide-chevron-left rtl:i-lucide-chevron-right"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="flex-1 flex items-center justify-center overflow-hidden">
<div
v-if="isImage"
:style="imageWrapperStyle"
class="flex items-center justify-center origin-center"
:class="{
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
// and prevent image from overflowing container in different aspect ratios
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
activeImageRotation % 180 !== 0,
'size-full': activeImageRotation % 180 === 0,
}"
>
<img
ref="imageRef"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
:style="imageStyle"
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
@click.stop
@dblclick.stop="onDoubleClickZoomImage"
@wheel.prevent.stop="onWheelImageZoom"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
/>
</div>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="max-h-full max-w-full object-contain"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="w-full max-w-md"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="ltr:i-lucide-chevron-right rtl:i-lucide-chevron-left"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === allAttachments.length - 1"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</main>
<footer
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
>
<div
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
>
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</div>
</footer>
</div>
</woot-modal>
</TeleportWithDirection>
</template>

View File

@@ -0,0 +1,140 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
parentWidth: {
type: Number,
default: 1000,
},
});
const REFRESH_INTERVAL = 60000;
const { t } = useI18n();
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
);
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
});
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const groupClass = computed(() => {
return props.showExtendedInfo
? 'h-[26px] rounded-lg bg-n-alpha-1'
: 'rounded h-5 border border-n-strong';
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
watch(
() => props.chat,
() => {
updateSlaStatus();
}
);
const slaPopoverClass = computed(() => {
return props.showExtendedInfo
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
: '';
});
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasSlaThreshold"
class="relative flex items-center cursor-pointer min-w-fit group"
:class="groupClass"
>
<div
class="flex items-center w-full truncate px-1.5"
:class="showExtendedInfo ? '' : 'gap-1'"
>
<div class="flex items-center gap-1" :class="slaPopoverClass">
<fluent-icon
size="12"
:icon="slaStatus.icon"
type="outline"
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
class="flex-shrink-0"
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo && parentWidth > 650"
class="text-xs font-medium"
:class="slaTextStyles"
>
{{ slaStatusText }}
</span>
</div>
<span
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
</span>
</div>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const { SLA_MISS_TYPES } = wootConstants;
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('SLA.EVENTS.TITLE') }}
</span>
<SLAEventItem
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLAEventItem
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<Button
link
xs
slate
class="hover:!no-underline"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
:label="
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
"
@click="toggleShowAllNRT"
/>
</div>
</template>
</SLAEventItem>
<SLAEventItem
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>

View File

@@ -0,0 +1,407 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js';
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
},
hasUnreadMessages: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: null,
},
priority: {
type: String,
default: null,
},
conversationLabels: {
type: Array,
default: () => [],
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
'assignPriority',
'markAsUnread',
'markAsRead',
'assignAgent',
'assignTeam',
'assignLabel',
'removeLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
icon: 'mail',
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail-unread',
},
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
],
snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
},
priorityConfig: {
key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
},
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: MENU.LABEL,
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: MENU.AGENT,
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: MENU.TEAM,
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: MENU.DELETE,
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
...mapGetters({
labels: 'labels/getLabels',
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
this.currentUser,
this.currentAccountId
);
const filteredAgents = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
},
assignableAgents() {
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
generateMenuLabelConfig(option, type = 'text') {
return {
key: option.id,
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
...(type === 'team' && { label: option.name }),
};
},
},
};
</script>
<template>
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
/>
</template>
<MenuItem
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
Spinner,
},
};
</script>
<template>
<div class="agent-placeholder">
<Spinner />
<p>{{ $t('CONVERSATION.CARD_CONTEXT_MENU.AGENTS_LOADING') }}</p>
</div>
</template>
<style scoped lang="scss">
.agent-placeholder {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 1rem 0;
min-width: calc(6.25rem * 2);
p {
margin: 0.5rem 0 0 0;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
});
</script>
<template>
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
size="14"
class="flex-shrink-0"
/>
<span
v-if="
(variant === 'label' || variant === 'label-assigned') && option.color
"
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Avatar
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
<Icon
v-if="variant === 'label-assigned'"
icon="i-lucide-check"
class="flex-shrink-0 size-3.5 mr-1"
/>
</div>
</template>
<style scoped lang="scss">
.menu {
width: calc(6.25rem * 2);
@apply flex items-center flex-nowrap p-1 rounded-md overflow-hidden cursor-pointer;
.menu-label {
@apply my-0 mx-2 text-xs flex-shrink-0;
}
&:hover {
@apply bg-n-brand text-white;
}
}
.agent-thumbnail {
margin-top: 0 !important;
}
.label-pill {
@apply w-4 h-4 rounded-full border border-n-strong border-solid flex-shrink-0;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
defineProps({
option: {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
});
const menuRef = useTemplateRef('menuRef');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { bottom, right } = useElementBounding(menuRef);
// Vertical position
const verticalPosition = computed(() => {
const SUBMENU_HEIGHT = 240; // 15rem in pixels
const spaceBelow = windowHeight.value - bottom.value;
return spaceBelow < SUBMENU_HEIGHT ? 'bottom-0' : 'top-0';
});
// Horizontal position
const horizontalPosition = computed(() => {
const SUBMENU_WIDTH = 240;
const spaceRight = windowWidth.value - right.value;
return spaceRight < SUBMENU_WIDTH ? 'right-full' : 'left-full';
});
const submenuPosition = computed(() => [
verticalPosition.value,
horizontalPosition.value,
]);
</script>
<template>
<div
ref="menuRef"
class="text-n-slate-12 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-n-alpha-3/50 backdrop-blur-[100px] justify-between hover:bg-n-brand/10 cursor-pointer dark:hover:bg-n-solid-3"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
>
<div class="flex items-center h-4">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="my-0 mx-2 text-xs">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div
v-if="subMenuAvailable"
class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute hidden max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
:class="submenuPosition"
>
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
.menu-with-submenu {
min-width: calc(6.25rem * 2);
&:hover {
.submenu {
@apply block;
}
}
}
</style>

View File

@@ -0,0 +1,277 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useBranding } from 'shared/composables/useBranding';
// composables
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
components: {
Avatar,
NextButton,
},
props: {
suggestedLabels: {
type: Array,
required: true,
},
chatLabels: {
type: Array,
required: false,
default: () => [],
},
},
setup() {
const { captainTasksEnabled } = useCaptain();
const { replaceInstallationName } = useBranding();
return { captainTasksEnabled, replaceInstallationName };
},
data() {
return {
isDismissed: false,
isHovered: false,
selectedLabels: [],
};
},
computed: {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
currentChat: 'getSelectedChat',
}),
conversationId() {
return this.currentChat?.id;
},
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
},
addButtonText() {
if (this.selectedLabels.length === 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
}
if (this.selectedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
},
preparedLabels() {
return this.allLabels.filter(label =>
this.suggestedLabels.includes(label.title)
);
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.selectedLabels = [];
this.isDismissed = this.isConversationDismissed();
},
},
},
methods: {
pushOrAddLabel(label) {
if (this.preparedLabels.length === 1) {
this.addAllLabels();
return;
}
if (!this.selectedLabels.includes(label)) {
this.selectedLabels.push(label);
} else {
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
}
},
dismissSuggestions() {
LocalStorage.setFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
},
isConversationDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.conversationId
);
},
addAllLabels() {
let labelsToAdd = this.selectedLabels;
if (!labelsToAdd.length) {
labelsToAdd = this.preparedLabels.map(label => label.title);
}
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
conversationId: this.conversationId,
account: this.currentAccountId,
suggestions: this.suggestedLabels,
labelsApplied: this.selectedLabels.length
? this.selectedLabels
: this.suggestedLabels,
};
useTrack(event, payload);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<li
v-if="shouldShowSuggestions"
class="label-suggestion right list-none"
@mouseover="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="wrap">
<div class="label-suggestion--container">
<h6 class="label-suggestion--title">
{{ $t('LABEL_MGMT.SUGGESTIONS.SUGGESTED_LABELS') }}
</h6>
<div class="label-suggestion--options">
<button
v-for="label in preparedLabels"
:key="label.title"
v-tooltip.top="{
content: selectedLabels.includes(label.title)
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
: labelTooltip,
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option !px-0"
@click="pushOrAddLabel(label.title)"
>
<woot-label
variant="dashed"
v-bind="label"
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
/>
</button>
<NextButton
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
<div
v-if="preparedLabels.length > 1"
class="inline-flex items-center gap-1"
>
<NextButton
xs
icon="i-lucide-plus"
class="flex-shrink-0"
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
:label="addButtonText"
@click="addAllLabels"
/>
<NextButton
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
faded
xs
icon="i-lucide-x"
class="flex-shrink-0"
:color="isHovered ? 'ruby' : 'blue'"
@click="dismissSuggestions"
/>
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<Avatar
v-tooltip.top="{
content: replaceInstallationName(
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
:size="16"
name="chatwoot-ai"
icon-name="i-lucide-sparkles"
/>
</div>
</div>
</li>
</template>
<style scoped lang="scss">
.wrap {
display: flex;
}
.label-suggestion {
flex-direction: row;
justify-content: flex-end;
margin-top: 1rem;
.label-suggestion--container {
max-width: 300px;
}
.label-suggestion--options {
@apply gap-0.5 text-end flex items-center;
button.label-suggestion--option {
.label {
cursor: pointer;
margin-bottom: 0;
}
}
}
.label-suggestion--title {
@apply text-n-slate-11 mt-0.5 text-xxs;
}
}
</style>

View File

@@ -0,0 +1,254 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>

View File

@@ -0,0 +1,322 @@
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
},
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
},
};
</script>
<template>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { ref, computed, watch, onMounted, nextTick, useSlots } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
conversationLabels: {
type: Array,
required: true,
},
});
const slots = useSlots();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.conversationLabels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
const computeVisibleLabelPosition = () => {
const beforeSlot = slots.before ? 100 : 0;
if (!labelContainer.value) {
return;
}
const labels = Array.from(labelContainer.value.querySelectorAll('.label'));
let labelOffset = 0;
showExpandLabelButton.value = false;
labels.forEach((label, index) => {
labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.value.clientWidth - beforeSlot) {
labelPosition.value = index;
} else {
showExpandLabelButton.value = labels.length > 1;
}
});
};
watch(activeLabels, () => {
nextTick(() => computeVisibleLabelPosition());
});
onMounted(() => {
computeVisibleLabelPosition();
});
const onShowLabels = e => {
e.stopPropagation();
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div ref="labelContainer" v-resize="computeVisibleLabelPosition">
<div
v-if="activeLabels.length || $slots.before"
class="flex items-end flex-shrink min-w-0 gap-y-1"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<woot-label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
:title="label.title"
:description="label.description"
:color="label.color"
variant="smooth"
class="!mb-0 max-w-[calc(100%-0.5rem)]"
small
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<button
v-if="showExpandLabelButton"
:title="
showAllLabels
? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS')
"
class="h-5 py-0 px-1 flex-shrink-0 mr-6 ml-0 rtl:ml-6 rtl:mr-0 rtl:rotate-180 text-n-slate-11 border-n-strong dark:border-n-strong"
@click="onShowLabels"
>
<fluent-icon
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
size="12"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>

View File

@@ -0,0 +1,7 @@
import { email as emailValidator } from '@vuelidate/validators';
export const validEmailsByComma = value => {
if (!value.length) return true;
const emails = value.replace(/\s+/g, '').split(',');
return emails.every(email => emailValidator.$validator(email));
};

View File

@@ -0,0 +1,18 @@
const totalMessageHeight = (total, element) => {
return total + element.scrollHeight;
};
export const calculateScrollTop = (
conversationPanelHeight,
parentHeight,
relevantMessages
) => {
// add up scrollHeight of a `relevantMessages`
let combinedMessageScrollHeight = [...relevantMessages].reduce(
totalMessageHeight,
0
);
return (
conversationPanelHeight - combinedMessageScrollHeight - parentHeight / 2
);
};

View File

@@ -0,0 +1,16 @@
import { validEmailsByComma } from '../emailHeadHelper';
describe('#validEmailsByComma', () => {
it('returns true when empty string is passed', () => {
expect(validEmailsByComma('')).toEqual(true);
});
it('returns true when valid emails separated by comma is passed', () => {
expect(validEmailsByComma('ni@njan.com,po@va.da')).toEqual(true);
});
it('returns false when one of the email passed is invalid', () => {
expect(validEmailsByComma('ni@njan.com,pova.da')).toEqual(false);
});
it('strips spaces between emails before validating', () => {
expect(validEmailsByComma('1@test.com , 2@test.com')).toEqual(true);
});
});

View File

@@ -0,0 +1,18 @@
import { calculateScrollTop } from '../scrollTopCalculationHelper';
describe('#calculateScrollTop', () => {
it('returns calculated value of the scrollTop property', () => {
class DOMElement {
constructor(scrollHeight) {
this.scrollHeight = scrollHeight;
}
}
let count = 3;
let relevantMessages = [];
while (count > 0) {
relevantMessages.push(new DOMElement(100));
count -= 1;
}
expect(calculateScrollTop(1000, 300, relevantMessages)).toEqual(550);
});
});

View File

@@ -0,0 +1,273 @@
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const dropdowns = computed(() => {
return [
{
type: 'teamId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL',
items: teams.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.SEARCH',
error: teamError.value,
},
{
type: 'assigneeId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL',
items: assignees.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.SEARCH',
error: '',
},
{
type: 'labelId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL',
items: labels.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.SEARCH',
error: '',
},
{
type: 'priority',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL',
items: priorities,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.SEARCH',
error: '',
},
{
type: 'projectId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL',
items: projects.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.SEARCH',
error: '',
},
{
type: 'stateId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL',
items: statuses.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.SEARCH',
error: '',
},
];
});
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChange = (item, type) => {
formState[type] = item.id;
if (type === 'teamId') {
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
formState.projectId = '';
getTeamEntities();
}
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
<template>
<div>
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<div class="flex flex-col gap-4">
<SearchableDropdown
v-for="dropdown in dropdowns"
:key="dropdown.type"
:type="dropdown.type"
:value="formState[dropdown.type]"
:label="$t(dropdown.label)"
:items="dropdown.items"
:placeholder="$t(dropdown.placeholder)"
:error="dropdown.error"
@change="onChange"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<Button
faded
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose"
/>
<Button
type="submit"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE')"
:disabled="isSubmitDisabled"
:is-loading="isCreating"
@click.prevent="createIssue"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emit('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4 mt-1">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0 h-10"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<CreateIssue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<LinkIssue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const unlinkIssue = () => {
emit('unlinkIssue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
<template>
<div class="flex items-center justify-between">
<div
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
>
<div class="flex items-center gap-1">
<fluent-icon
icon="linear"
size="16"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-n-slate-12">
{{ identifier }}
</span>
</div>
<span class="w-px h-3 text-n-weak bg-n-weak" />
<Button
link
xs
slate
icon="i-lucide-arrow-up-right"
class="!size-4"
@click="openIssue"
/>
</div>
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
</div>
</template>

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