mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
Merge branch 'develop' into fix/hc-editor
This commit is contained in:
@@ -19,10 +19,10 @@ const onClick = () => {
|
||||
/>
|
||||
|
||||
<div
|
||||
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500"
|
||||
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-woot-500 dark:bg-woot-500 opacity-50"
|
||||
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import AILoader from './AILoader.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AILoader,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
aiOption: {
|
||||
@@ -68,11 +70,11 @@ export default {
|
||||
@submit.prevent="applyText"
|
||||
>
|
||||
<div v-if="draftMessage" class="w-full">
|
||||
<h4 class="mt-1 text-base text-slate-700 dark:text-slate-100">
|
||||
<h4 class="mt-1 text-base text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
|
||||
</h4>
|
||||
<p v-dompurify-html="formatMessage(draftMessage, false)" />
|
||||
<h4 class="mt-1 text-base text-slate-700 dark:text-slate-100">
|
||||
<h4 class="mt-1 text-base text-n-slate-12">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
|
||||
}}
|
||||
@@ -84,16 +86,22 @@ export default {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
{{
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="
|
||||
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
|
||||
}}
|
||||
</woot-button>
|
||||
<woot-button :disabled="!generatedContent">
|
||||
{{
|
||||
"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:disabled="!generatedContent"
|
||||
:label="
|
||||
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
|
||||
}}
|
||||
</woot-button>
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['close'],
|
||||
|
||||
setup() {
|
||||
@@ -94,16 +99,30 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
|
||||
<woot-button variant="link" @click.prevent="openOpenAIDoc">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP') }}
|
||||
</woot-button>
|
||||
<NextButton
|
||||
ghost
|
||||
type="button"
|
||||
class="!px-3"
|
||||
:label="
|
||||
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
|
||||
"
|
||||
@click.prevent="openOpenAIDoc"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<woot-button variant="clear" @click.prevent="onDismiss">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS') }}
|
||||
</woot-button>
|
||||
<woot-button :is-disabled="v$.value.$invalid">
|
||||
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH') }}
|
||||
</woot-button>
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="
|
||||
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
|
||||
"
|
||||
@click.prevent="onDismiss"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:disabled="v$.value.$invalid"
|
||||
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -22,23 +22,15 @@
|
||||
gap: 4px;
|
||||
|
||||
.ai-typing--icon {
|
||||
color: var(--v-500);
|
||||
@apply text-n-iris-11;
|
||||
}
|
||||
}
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-right: var(--space-smaller);
|
||||
color: var(--v-400);
|
||||
@apply text-n-iris-11 ltr:mr-1 rtl:ml-1 inline-block;
|
||||
}
|
||||
.loader {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: var(--space-smaller);
|
||||
margin-top: var(--space-slab);
|
||||
background-color: var(--v-300);
|
||||
border-radius: 50%;
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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,
|
||||
@@ -48,7 +50,7 @@ const fileName = file => {
|
||||
<div
|
||||
v-for="(attachment, index) in nonRecordedAudioAttachments"
|
||||
:key="attachment.id"
|
||||
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
|
||||
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
|
||||
>
|
||||
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
||||
<img
|
||||
@@ -73,9 +75,11 @@ const fileName = file => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<woot-button
|
||||
class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
|
||||
icon="dismiss"
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
@click="onRemoveAttachment(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AutomationActionTeamMessageInput,
|
||||
AutomationActionFileInput,
|
||||
WootMessageEditor,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
@@ -130,7 +133,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
@@ -149,7 +156,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="inputType === 'email'"
|
||||
@@ -172,11 +183,12 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="!isMacro"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
ghost
|
||||
class="flex-shrink-0"
|
||||
@click="removeAction"
|
||||
/>
|
||||
</div>
|
||||
@@ -201,10 +213,10 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
|
||||
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
|
||||
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,15 +225,19 @@ export default {
|
||||
}
|
||||
|
||||
.filter.has-error {
|
||||
@apply bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50;
|
||||
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
|
||||
|
||||
&.is-a-macro {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-inputs {
|
||||
@apply flex;
|
||||
@apply flex gap-1;
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.action__question,
|
||||
@@ -258,11 +274,11 @@ export default {
|
||||
@apply flex items-center justify-center relative my-2.5 mx-0;
|
||||
|
||||
.operator__line {
|
||||
@apply absolute w-full border-b border-solid border-slate-75 dark:border-slate-600;
|
||||
@apply absolute w-full border-b border-solid border-n-weak;
|
||||
}
|
||||
|
||||
.operator__select {
|
||||
margin-bottom: var(--space-zero) !important;
|
||||
margin-bottom: 0 !important;
|
||||
@apply relative w-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small">
|
||||
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
|
||||
<multiselect
|
||||
v-model="selectedTeams"
|
||||
track-by="id"
|
||||
@@ -56,9 +56,9 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.multiselect {
|
||||
margin: var(--space-smaller) var(--space-zero);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
textarea {
|
||||
margin-bottom: var(--space-zero);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,13 +79,13 @@ input[type='file'] {
|
||||
@apply hidden;
|
||||
}
|
||||
.input-wrapper {
|
||||
@apply flex h-9 bg-white dark:bg-slate-900 py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-woot-100 dark:border-woot-500;
|
||||
@apply flex h-9 bg-n-background py-1 px-2 items-center text-xs cursor-pointer rounded-sm border border-dashed border-n-strong;
|
||||
}
|
||||
.success-icon {
|
||||
@apply text-green-500 dark:text-green-600 mr-2;
|
||||
@apply text-n-teal-9 mr-2;
|
||||
}
|
||||
.error-icon {
|
||||
@apply text-red-500 dark:text-red-600 mr-2;
|
||||
@apply text-n-ruby-9 mr-2;
|
||||
}
|
||||
|
||||
.processing {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'Avatar',
|
||||
props: {
|
||||
username: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 40,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
return {
|
||||
fontSize: `${Math.floor(this.size / 2.5)}px`,
|
||||
};
|
||||
},
|
||||
userInitial() {
|
||||
const parts = this.username.split(/[ -]/);
|
||||
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
|
||||
|
||||
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
|
||||
initials = initials.replace(/[a-z]+/g, '');
|
||||
}
|
||||
initials = initials.substring(0, 2).toUpperCase();
|
||||
|
||||
return initials;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar-container" :style="style" aria-hidden="true">
|
||||
<slot>{{ userInitial }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
@layer components {
|
||||
.avatar-color {
|
||||
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
|
||||
}
|
||||
|
||||
.dark-avatar-color {
|
||||
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
|
||||
}
|
||||
}
|
||||
.avatar-container {
|
||||
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-woot-600 dark:text-woot-200;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import router from '../../routes/index';
|
||||
const props = defineProps({
|
||||
backUrl: {
|
||||
@@ -24,24 +23,16 @@ const goBack = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buttonStyleClass = props.compact
|
||||
? 'text-sm text-n-slate-11'
|
||||
: 'text-base text-n-blue-text';
|
||||
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center p-0 font-normal cursor-pointer gap-1"
|
||||
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
|
||||
:class="buttonStyleClass"
|
||||
@click.capture="goBack"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-left"
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
:class="
|
||||
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
|
||||
"
|
||||
/>
|
||||
<i class="i-lucide-chevron-left -ml-1 text-lg" />
|
||||
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
hasFbConfigured() {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
},
|
||||
hasInstagramConfigured() {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
},
|
||||
isActive() {
|
||||
const { key } = this.channel;
|
||||
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||
@@ -32,6 +35,16 @@ export default {
|
||||
return this.enabledFeatures.channel_email;
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return (
|
||||
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return this.enabledFeatures.channel_voice;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
@@ -40,8 +53,16 @@ export default {
|
||||
'sms',
|
||||
'telegram',
|
||||
'line',
|
||||
'instagram',
|
||||
'voice',
|
||||
].includes(key);
|
||||
},
|
||||
isComingSoon() {
|
||||
const { key } = this.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) && !this.isActive;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getChannelThumbnail() {
|
||||
@@ -64,6 +85,7 @@ export default {
|
||||
:class="{ inactive: !isActive }"
|
||||
:title="channel.name"
|
||||
:src="getChannelThumbnail()"
|
||||
:is-coming-soon="isComingSoon"
|
||||
@click="onItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -48,25 +48,17 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-4 py-0 tab--chat-type"
|
||||
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
|
||||
@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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab--chat-type {
|
||||
::v-deep {
|
||||
.tabs {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,19 +52,16 @@ export default {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
@import 'dashboard/assets/scss/mixins';
|
||||
|
||||
.colorpicker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.colorpicker--selected {
|
||||
@apply border border-solid border-slate-50 dark:border-slate-600 rounded cursor-pointer h-8 w-8 mb-4;
|
||||
@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-slate-75 dark:border-slate-600 rounded;
|
||||
@apply shadow-lg -mt-2.5 absolute z-[9999] border border-solid border-n-weak rounded;
|
||||
|
||||
::v-deep {
|
||||
input {
|
||||
|
||||
@@ -11,13 +11,13 @@ export default {
|
||||
<div class="empty-state py-16 px-1 ml-0 mr-0">
|
||||
<h3
|
||||
v-if="title"
|
||||
class="text-slate-700 dark:text-slate-200 block text-center w-full text-xl font-thin"
|
||||
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-slate-500 dark:text-slate-400 my-4 mx-auto w-[90%]"
|
||||
class="block text-center text-n-slate-11 my-4 mx-auto w-[90%]"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'FilterInput',
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
@@ -127,8 +132,8 @@ export default {
|
||||
},
|
||||
getInputErrorClass(errorMessage) {
|
||||
return errorMessage
|
||||
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
|
||||
: 'bg-slate-50 dark:bg-slate-800 border-slate-75 dark:border-slate-700/50';
|
||||
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
|
||||
: 'bg-n-background border-n-weak dark:border-n-weak';
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -138,14 +143,14 @@ export default {
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="p-2 border border-solid rounded-md"
|
||||
class="p-2 border border-solid rounded-lg"
|
||||
:class="getInputErrorClass(errorMessage)"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex gap-1">
|
||||
<select
|
||||
v-if="groupedFilters"
|
||||
v-model="attributeKey"
|
||||
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<optgroup
|
||||
@@ -166,7 +171,7 @@ export default {
|
||||
<select
|
||||
v-else
|
||||
v-model="attributeKey"
|
||||
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
class="max-w-[30%] mb-0 mr-1"
|
||||
@change="resetFilter()"
|
||||
>
|
||||
<option
|
||||
@@ -179,10 +184,7 @@ export default {
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
v-model="filterOperator"
|
||||
class="bg-white dark:bg-slate-900 max-w-[20%] mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
>
|
||||
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
|
||||
<option
|
||||
v-for="(operator, o) in operators"
|
||||
:key="o"
|
||||
@@ -201,7 +203,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
@@ -209,7 +211,11 @@ export default {
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'search_select'"
|
||||
@@ -219,7 +225,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
@@ -227,28 +233,33 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
|
||||
<input
|
||||
v-model="values"
|
||||
type="date"
|
||||
:editable="false"
|
||||
class="mb-0 datepicker"
|
||||
class="!mb-0 datepicker"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-else
|
||||
v-model="values"
|
||||
type="text"
|
||||
class="mb-0"
|
||||
class="!mb-0"
|
||||
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
<NextButton
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
ghost
|
||||
class="flex-shrink-0"
|
||||
@click="removeFilter"
|
||||
/>
|
||||
</div>
|
||||
@@ -261,12 +272,10 @@ export default {
|
||||
v-if="showQueryOperator"
|
||||
class="flex items-center justify-center relative my-2.5 mx-0"
|
||||
>
|
||||
<hr
|
||||
class="absolute w-full border-b border-solid border-slate-75 dark:border-slate-800"
|
||||
/>
|
||||
<hr class="absolute w-full border-b border-solid border-n-weak" />
|
||||
<select
|
||||
v-model="query_operator"
|
||||
class="relative w-auto mb-0 bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
|
||||
class="relative w-auto mb-0 bg-n-background text-n-slate-12 border-n-weak"
|
||||
>
|
||||
<option value="and">
|
||||
{{ $t('FILTER.QUERY_DROPDOWN_LABELS.AND') }}
|
||||
@@ -282,12 +291,12 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.filter__answer--wrap {
|
||||
input {
|
||||
@apply bg-white dark:bg-slate-900 mb-0 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600;
|
||||
@apply bg-n-background mb-0 text-n-slate-12 border-n-weak;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-error {
|
||||
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<script>
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getReadableInboxByType,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxIdentifier: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
channelType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computedInboxIcon() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getInboxClassByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
computedInboxType() {
|
||||
if (!this.channelType) return 'chat';
|
||||
const classByType = getReadableInboxByType(
|
||||
this.channelType,
|
||||
this.inboxIdentifier
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center h-[2.375rem] min-w-0 py-0 px-1">
|
||||
<span
|
||||
class="inline-flex rounded mr-1 rtl:ml-1 rtl:mr-0 bg-slate-25 dark:bg-slate-700 p-0.5 items-center flex-shrink-0 justify-center w-6 h-6"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="computedInboxIcon"
|
||||
size="14"
|
||||
class="text-slate-800 dark:text-slate-200"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex flex-col w-full min-w-0 ml-1 mr-1">
|
||||
<h5 class="option__title">
|
||||
{{ name }}
|
||||
</h5>
|
||||
<p
|
||||
class="option__body overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
:title="inboxIdentifier"
|
||||
>
|
||||
{{ inboxIdentifier || computedInboxType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option__body {
|
||||
@apply inline-block text-slate-600 dark:text-slate-200 leading-[1.3] min-w-0 m-0;
|
||||
}
|
||||
.option__title {
|
||||
@apply leading-[1.1] text-xs m-0 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,22 @@
|
||||
<script>
|
||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
computed: {
|
||||
computedInboxClass() {
|
||||
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
|
||||
const classByType = getInboxClassByType(type, phoneNumber);
|
||||
return classByType;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
|
||||
>
|
||||
<fluent-icon
|
||||
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
|
||||
:icon="computedInboxClass"
|
||||
size="12"
|
||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||
<ChannelIcon
|
||||
:inbox="inbox"
|
||||
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||
/>
|
||||
{{ inbox.name }}
|
||||
<span class="truncate">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
allLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
savedLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'remove']);
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showSearchDropdownLabel = ref(false);
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
return props.savedLabels.map(label => label.title);
|
||||
});
|
||||
|
||||
const addItem = label => {
|
||||
emit('add', label);
|
||||
};
|
||||
|
||||
const removeItem = label => {
|
||||
emit('remove', label);
|
||||
};
|
||||
|
||||
const toggleLabels = () => {
|
||||
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
|
||||
};
|
||||
|
||||
const closeDropdownLabel = () => {
|
||||
showSearchDropdownLabel.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
toggleLabels();
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
Escape: {
|
||||
action: () => closeDropdownLabel(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
||||
<AddLabel @add="toggleLabels" />
|
||||
<woot-label
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:title="label.title"
|
||||
:description="label.description"
|
||||
show-close
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
<div class="absolute w-full top-7">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
|
||||
class="!box-border !w-full dropdown-pane"
|
||||
>
|
||||
<LabelDropdown
|
||||
v-if="showSearchDropdownLabel"
|
||||
:account-labels="allLabels"
|
||||
:selected-labels="selectedLabels"
|
||||
:allow-creation="isAdmin"
|
||||
@add="addItem"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,20 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
message: { type: String, default: '' },
|
||||
},
|
||||
};
|
||||
<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-2 text-base text-center w-100 text-slate-800 dark:text-slate-300"
|
||||
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
|
||||
>
|
||||
<span class="text-base font-medium text-slate-800 dark:text-slate-100">
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ message }}
|
||||
</span>
|
||||
<span class="spinner" />
|
||||
<Spinner class="text-n-brand" />
|
||||
</h6>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,16 +14,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak">
|
||||
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="headerContent"
|
||||
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
|
||||
>
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot />
|
||||
<div class="border-b border-solid border-n-weak/60">
|
||||
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
|
||||
<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>
|
||||
|
||||
@@ -37,7 +37,7 @@ const toggleShowMore = () => {
|
||||
{{ textToBeDisplayed }}
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="text-woot-500 !p-0 !border-0 align-top"
|
||||
class="text-n-brand !p-0 !border-0 align-top"
|
||||
@click="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
@@ -21,13 +21,6 @@ const hasFirstPage = computed(() => props.currentPage === 1);
|
||||
const hasNextPage = computed(() => props.currentPage === props.totalPages);
|
||||
const hasPrevPage = computed(() => props.currentPage === 1);
|
||||
|
||||
function buttonClass(hasPage) {
|
||||
if (hasPage) {
|
||||
return 'hover:!bg-slate-50 dark:hover:!bg-slate-800';
|
||||
}
|
||||
return 'dark:hover:!bg-slate-700/50';
|
||||
}
|
||||
|
||||
function onPageChange(newPage) {
|
||||
emit('pageChange', newPage);
|
||||
}
|
||||
@@ -55,84 +48,61 @@ const onLastPage = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center h-8 rounded-lg bg-slate-50 dark:bg-slate-800">
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasFirstPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-l-lg ltr:rounded-r-none rtl:rounded-r-lg rtl:rounded-l-none"
|
||||
:class="buttonClass(hasFirstPage)"
|
||||
<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"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-left"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasFirstPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="w-px h-4 rounded-sm bg-slate-75 dark:bg-slate-700/50" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasPrevPage)"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-left-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasPrevPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
|
||||
/>
|
||||
<div
|
||||
class="flex items-center gap-3 px-3 tabular-nums bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-100"
|
||||
class="flex items-center gap-3 px-3 tabular-nums bg-n-slate-9/10 h-full"
|
||||
>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-75">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-500">/</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-500">
|
||||
<span class="text-n-slate-11">/</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ totalPages }}
|
||||
</span>
|
||||
</div>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 rounded-none"
|
||||
:class="buttonClass(hasNextPage)"
|
||||
<NextButton
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rounded-none"
|
||||
:disabled="hasNextPage"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevron-right-single"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasNextPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
<div class="w-px h-4 rounded-sm bg-slate-75 dark:bg-slate-700/50" />
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class-names="dark:!bg-slate-800 !opacity-100 ltr:rounded-r-lg ltr:rounded-l-none rtl:rounded-l-lg rtl:rounded-r-none"
|
||||
:class="buttonClass(hasLastPage)"
|
||||
:is-disabled="hasLastPage"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="chevrons-right"
|
||||
size="20"
|
||||
icon-lib="lucide"
|
||||
:class="hasLastPage && 'opacity-40'"
|
||||
/>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200 font-medium">
|
||||
<span class="text-sm text-n-slate-11 font-medium">
|
||||
{{
|
||||
$t('GENERAL.SHOWING_RESULTS', {
|
||||
firstIndex,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import Avatar from './Avatar.vue';
|
||||
import Thumbnail from './Thumbnail.vue';
|
||||
|
||||
describe('Thumbnail.vue', () => {
|
||||
it('should render the agent thumbnail if valid image is passed', () => {
|
||||
const wrapper = mount(Thumbnail, {
|
||||
propsData: {
|
||||
src: 'https://some_valid_url.com',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: true,
|
||||
imgError: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render the avatar component if invalid image is passed', () => {
|
||||
const wrapper = mount(Thumbnail, {
|
||||
propsData: {
|
||||
src: 'https://some_invalid_url.com',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: true,
|
||||
imgError: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('#image').exists()).toBe(false);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('should the initial of the name if no image is passed', () => {
|
||||
const wrapper = mount(Avatar, {
|
||||
propsData: {
|
||||
username: 'Angie Rojas',
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('div').text()).toBe('AR');
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
<script>
|
||||
/**
|
||||
* Thumbnail Component
|
||||
* Src - source for round image
|
||||
* Size - Size of the thumbnail
|
||||
* Badge - Chat source indication { fb / telegram }
|
||||
* Username - Username for avatar
|
||||
*/
|
||||
import Avatar from './Avatar.vue';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Avatar,
|
||||
},
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '40px',
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasBorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shouldShowStatusAlways: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'circle',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageLoaded: false,
|
||||
imgError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userNameWithoutEmoji() {
|
||||
return removeEmoji(this.username);
|
||||
},
|
||||
showStatusIndicator() {
|
||||
if (this.shouldShowStatusAlways) return true;
|
||||
return this.status === 'online' || this.status === 'busy';
|
||||
},
|
||||
avatarSize() {
|
||||
return Number(this.size.replace(/\D+/g, ''));
|
||||
},
|
||||
badgeSrc() {
|
||||
return {
|
||||
instagram_direct_message: 'instagram-dm',
|
||||
facebook: 'messenger',
|
||||
'twitter-tweet': 'twitter-tweet',
|
||||
'twitter-dm': 'twitter-dm',
|
||||
whatsapp: 'whatsapp',
|
||||
sms: 'sms',
|
||||
'Channel::Line': 'line',
|
||||
'Channel::Telegram': 'telegram',
|
||||
'Channel::WebWidget': '',
|
||||
}[this.badge];
|
||||
},
|
||||
badgeStyle() {
|
||||
const size = Math.floor(this.avatarSize / 3);
|
||||
const badgeSize = `${size + 2}px`;
|
||||
const borderRadius = `${size / 2}px`;
|
||||
return { width: badgeSize, height: badgeSize, borderRadius };
|
||||
},
|
||||
statusStyle() {
|
||||
const statusSize = `${this.avatarSize / 4}px`;
|
||||
return { width: statusSize, height: statusSize };
|
||||
},
|
||||
thumbnailClass() {
|
||||
const className = this.hasBorder
|
||||
? 'border border-solid border-white dark:border-slate-700/50'
|
||||
: '';
|
||||
const variant =
|
||||
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
|
||||
return `user-thumbnail ${className} ${variant}`;
|
||||
},
|
||||
thumbnailBoxClass() {
|
||||
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
|
||||
return `user-thumbnail-box ${boxClass}`;
|
||||
},
|
||||
shouldShowImage() {
|
||||
if (!this.src) {
|
||||
return false;
|
||||
}
|
||||
if (this.hasImageLoaded) {
|
||||
return !this.imgError;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
src(value, oldValue) {
|
||||
if (value !== oldValue && this.imgError) {
|
||||
this.imgError = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.imgError = true;
|
||||
},
|
||||
onImgLoad() {
|
||||
this.hasImageLoaded = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="thumbnailBoxClass"
|
||||
:style="{ height: size, width: size }"
|
||||
:title="title"
|
||||
>
|
||||
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
|
||||
<slot>
|
||||
<img
|
||||
v-show="shouldShowImage"
|
||||
:src="src"
|
||||
draggable="false"
|
||||
:class="thumbnailClass"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<Avatar
|
||||
v-show="!shouldShowImage"
|
||||
:username="userNameWithoutEmoji"
|
||||
:class="thumbnailClass"
|
||||
:size="avatarSize"
|
||||
/>
|
||||
</slot>
|
||||
<img
|
||||
v-if="badgeSrc"
|
||||
class="source-badge"
|
||||
:style="badgeStyle"
|
||||
:src="`/integrations/channels/badges/${badgeSrc}.png`"
|
||||
alt="Badge"
|
||||
/>
|
||||
<div
|
||||
v-if="showStatusIndicator"
|
||||
:class="`source-badge user-online-status user-online-status--${status}`"
|
||||
:style="statusStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-thumbnail-box {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.is-rounded {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-thumbnail {
|
||||
border-radius: 50%;
|
||||
&.thumbnail-square {
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
object-fit: cover;
|
||||
vertical-align: initial;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
border-radius: var(--border-radius-small);
|
||||
bottom: var(--space-minus-micro);
|
||||
box-shadow: var(--shadow-small);
|
||||
height: var(--space-slab);
|
||||
padding: var(--space-micro);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: var(--space-slab);
|
||||
@apply bg-white dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.user-online-status {
|
||||
border-radius: 50%;
|
||||
bottom: var(--space-micro);
|
||||
|
||||
&:after {
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.user-online-status--online {
|
||||
@apply bg-green-400 dark:bg-green-400;
|
||||
}
|
||||
|
||||
.user-online-status--busy {
|
||||
@apply bg-yellow-500 dark:bg-yellow-500;
|
||||
}
|
||||
|
||||
.user-online-status--offline {
|
||||
@apply bg-slate-500 dark:bg-slate-500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +1,69 @@
|
||||
<script>
|
||||
import Thumbnail from './Thumbnail.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
const props = defineProps({
|
||||
usersList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
props: {
|
||||
usersList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '24px',
|
||||
},
|
||||
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);
|
||||
},
|
||||
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="overlapping-thumbnails">
|
||||
<Thumbnail
|
||||
<div class="flex">
|
||||
<Avatar
|
||||
v-for="user in usersList"
|
||||
:key="user.id"
|
||||
v-tooltip="user.name"
|
||||
:title="user.name"
|
||||
:src="user.thumbnail"
|
||||
:username="user.name"
|
||||
has-border
|
||||
:name="user.name"
|
||||
:size="size"
|
||||
:class="`overlapping-thumbnail gap-${gap}`"
|
||||
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
|
||||
:class="gapClass"
|
||||
rounded-full
|
||||
/>
|
||||
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.overlapping-thumbnails {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.overlapping-thumbnail {
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-small);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
|
||||
.gap-tight {
|
||||
margin-left: var(--space-minus-small);
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-more-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
margin-left: var(--space-minus-small);
|
||||
padding: 0 var(--space-small);
|
||||
box-shadow: var(--shadow-small);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--space-giga);
|
||||
border: 1px solid var(--white);
|
||||
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
user: {
|
||||
@@ -7,8 +7,8 @@ defineProps({
|
||||
default: () => ({}),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '20px',
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
textClass: {
|
||||
type: String,
|
||||
@@ -19,16 +19,15 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 text-left">
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
:src="user.thumbnail"
|
||||
:size="size"
|
||||
:username="user.name"
|
||||
:name="user.name"
|
||||
:status="user.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
:class="textClass"
|
||||
>
|
||||
<span class="my-0 truncate text-capitalize" :class="textClass">
|
||||
{{ user.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -72,6 +73,7 @@ const props = defineProps({
|
||||
updateSelectionWith: { type: String, default: '' },
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
signature: { type: String, default: '' },
|
||||
@@ -89,6 +91,7 @@ const emit = defineEmits([
|
||||
'toggleUserMention',
|
||||
'toggleCannedMenu',
|
||||
'toggleVariablesMenu',
|
||||
'toggleToolsMenu',
|
||||
'clearSelection',
|
||||
'blur',
|
||||
'focus',
|
||||
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
|
||||
const showCannedMenu = ref(false);
|
||||
const showVariables = ref(false);
|
||||
const showEmojiMenu = ref(false);
|
||||
const showToolsMenu = ref(false);
|
||||
const mentionSearchKey = ref('');
|
||||
const toolSearchKey = ref('');
|
||||
const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
@@ -216,10 +221,17 @@ const plugins = computed(() => {
|
||||
}
|
||||
|
||||
return [
|
||||
createSuggestionPlugin({
|
||||
trigger: '@',
|
||||
showMenu: showToolsMenu,
|
||||
searchTerm: toolSearchKey,
|
||||
isAllowed: () => props.enableCaptainTools,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '@',
|
||||
showMenu: showUserMentions,
|
||||
searchTerm: mentionSearchKey,
|
||||
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '/',
|
||||
@@ -261,6 +273,9 @@ watch(showCannedMenu, updatedValue => {
|
||||
watch(showVariables, updatedValue => {
|
||||
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
|
||||
});
|
||||
watch(showToolsMenu, updatedValue => {
|
||||
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
|
||||
});
|
||||
|
||||
function focusEditorInputField(pos = 'end') {
|
||||
const { tr } = editorView.state;
|
||||
@@ -537,6 +552,7 @@ function insertSpecialContent(type, content) {
|
||||
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
||||
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
||||
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
||||
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
|
||||
};
|
||||
|
||||
useTrack(event_map[type]);
|
||||
@@ -698,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
:search-key="emojiSearchTerm"
|
||||
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
|
||||
/>
|
||||
<TagTools
|
||||
v-if="showToolsMenu"
|
||||
:search-key="toolSearchKey"
|
||||
@select-tool="content => insertSpecialContent('tool', content)"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
type="file"
|
||||
@@ -708,7 +729,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
<div ref="editor" />
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
:style="{
|
||||
top: toolbarPosition.top,
|
||||
left: toolbarPosition.left,
|
||||
@@ -717,7 +738,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
<button
|
||||
v-for="size in sizes"
|
||||
:key="size.name"
|
||||
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
|
||||
@click="setURLWithQueryAndImageSize(size)"
|
||||
>
|
||||
{{ size.name }}
|
||||
@@ -734,16 +755,16 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply flex flex-col;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
min-height: var(--space-two) !important;
|
||||
min-height: 1.25rem !important;
|
||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-slate-75 dark:bg-slate-800;
|
||||
@apply bg-n-slate-5 dark:bg-n-solid-3;
|
||||
}
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
@apply p-0 break-words text-slate-800 dark:text-slate-100;
|
||||
@apply p-0 break-words text-n-slate-12;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
@@ -752,14 +773,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-slate-400 dark:border-slate-500;
|
||||
@apply border-n-slate-7;
|
||||
|
||||
p {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,42 +795,60 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800 shadow-lg;
|
||||
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply dark:text-slate-25 text-slate-800;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-private {
|
||||
.prosemirror-mention-node {
|
||||
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-slate-900 dark:text-slate-25 py-0 px-1;
|
||||
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-n-slate-12 py-0 px-1;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
> .ProseMirror {
|
||||
@apply text-slate-800 dark:text-slate-25;
|
||||
@apply text-n-slate-12;
|
||||
|
||||
p {
|
||||
@apply text-slate-800 dark:text-slate-25;
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prosemirror-tools-node {
|
||||
@apply font-medium text-n-slate-12 py-0;
|
||||
}
|
||||
|
||||
.editor-wrap {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.message-editor {
|
||||
@apply border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md py-0 px-1 mb-0;
|
||||
@apply rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2 py-0 px-1 mb-0;
|
||||
}
|
||||
|
||||
.editor_warning {
|
||||
@apply border border-solid border-red-400 dark:border-red-400;
|
||||
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
|
||||
}
|
||||
|
||||
.editor-warning__message {
|
||||
@apply text-red-400 dark:text-red-400 font-normal text-sm pt-1 pb-0 px-0;
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -331,11 +331,24 @@ export default {
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
z-index: var(--z-index-highest);
|
||||
background: var(--white);
|
||||
box-shadow: var(--shadow-large);
|
||||
border-radius: var(--border-radius-normal);
|
||||
border: 1px solid var(--color-border);
|
||||
min-width: 25rem;
|
||||
@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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
|
||||
} from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
@@ -93,7 +94,7 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasWhatsappTemplates: {
|
||||
enableWhatsAppTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -113,6 +114,10 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
@@ -127,7 +132,7 @@ export default {
|
||||
const uploadRef = ref(false);
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyA': {
|
||||
'$mod+Alt+KeyA': {
|
||||
action: () => {
|
||||
// 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.
|
||||
@@ -164,11 +169,6 @@ export default {
|
||||
'is-note-mode': this.isNote,
|
||||
};
|
||||
},
|
||||
buttonClass() {
|
||||
return {
|
||||
warning: this.isNote,
|
||||
};
|
||||
},
|
||||
showAttachButton() {
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
@@ -192,6 +192,9 @@ export default {
|
||||
showAudioPlayStopButton() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
isInstagramDM() {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
@@ -199,6 +202,10 @@ export default {
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
}
|
||||
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
@@ -326,7 +333,7 @@ export default {
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="hasWhatsappTemplates"
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
@@ -348,10 +355,10 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 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-slate-900 dark:text-slate-50">
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
|
||||
</h4>
|
||||
</div>
|
||||
@@ -367,14 +374,15 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<woot-button
|
||||
size="small"
|
||||
:class-names="buttonClass"
|
||||
:is-disabled="isSendDisabled"
|
||||
<NextButton
|
||||
:label="sendButtonText"
|
||||
type="submit"
|
||||
sm
|
||||
:color="isNote ? 'amber' : 'blue'"
|
||||
:disabled="isSendDisabled"
|
||||
class="flex-shrink-0"
|
||||
@click="onSend"
|
||||
>
|
||||
{{ sendButtonText }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -394,7 +402,7 @@ export default {
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
@apply dark:bg-slate-800 bg-slate-100;
|
||||
@apply enabled:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
};
|
||||
},
|
||||
charLengthClass() {
|
||||
return this.charactersRemaining < 0 ? 'text-red-600' : 'text-slate-600';
|
||||
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
|
||||
},
|
||||
characterLengthWarning() {
|
||||
return this.charactersRemaining < 0
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
class="mt-3"
|
||||
@@ -106,43 +106,3 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button-group {
|
||||
@apply flex border-0 p-0 m-0;
|
||||
|
||||
.button {
|
||||
@apply text-sm font-medium py-2.5 px-4 m-0 relative z-10;
|
||||
|
||||
&.is-active {
|
||||
@apply bg-white dark:bg-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
.button--reply {
|
||||
@apply border-r rounded-none border-b-0 border-l-0 border-t-0 border-slate-50 dark:border-slate-700;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
@apply border-r border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
}
|
||||
|
||||
.button--note {
|
||||
@apply border-l-0 rounded-none;
|
||||
|
||||
&.is-active {
|
||||
@apply border-r border-b-0 bg-yellow-100 dark:bg-yellow-800 border-t-0 border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
@apply text-yellow-700 dark:text-yellow-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button--note {
|
||||
@apply text-yellow-600 dark:text-yellow-600 bg-transparent dark:bg-transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,13 +52,13 @@ onMounted(() => {
|
||||
>
|
||||
<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-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
|
||||
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-woot-500 dark:text-woot-500': selected,
|
||||
'text-n-brand': selected,
|
||||
'font-normal': !selected,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
status: { type: String, default: '' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="`status-badge status-badge__${status} rounded-full w-2.5 h-2.5 mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-flex`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-badge {
|
||||
&__online {
|
||||
@apply bg-green-400;
|
||||
}
|
||||
&__offline {
|
||||
@apply bg-slate-500;
|
||||
}
|
||||
&__busy {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
<!-- // not using this component -->
|
||||
@@ -1,93 +1,132 @@
|
||||
<script>
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { mapGetters } from 'vuex';
|
||||
import FilterItem from './FilterItem.vue';
|
||||
<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';
|
||||
|
||||
const CHAT_STATUS_FILTER_ITEMS = Object.freeze([
|
||||
'open',
|
||||
'resolved',
|
||||
'pending',
|
||||
'snoozed',
|
||||
'all',
|
||||
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 SORT_ORDER_ITEMS = Object.freeze([
|
||||
'last_activity_at_asc',
|
||||
'last_activity_at_desc',
|
||||
'created_at_desc',
|
||||
'created_at_asc',
|
||||
'priority_desc',
|
||||
'priority_asc',
|
||||
'waiting_since_asc',
|
||||
'waiting_since_desc',
|
||||
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',
|
||||
},
|
||||
]);
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterItem,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['changeFilter'],
|
||||
setup() {
|
||||
const { updateUISettings } = useUISettings();
|
||||
const activeChatStatusLabel = computed(
|
||||
() =>
|
||||
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
|
||||
?.label || ''
|
||||
);
|
||||
|
||||
return {
|
||||
updateUISettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showActionsDropdown: false,
|
||||
chatStatusItems: CHAT_STATUS_FILTER_ITEMS,
|
||||
chatSortItems: SORT_ORDER_ITEMS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
chatStatusFilter: 'getChatStatusFilter',
|
||||
chatSortFilter: 'getChatSortFilter',
|
||||
}),
|
||||
chatStatus() {
|
||||
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
|
||||
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,
|
||||
},
|
||||
sortFilter() {
|
||||
return (
|
||||
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onTabChange(value) {
|
||||
this.$emit('changeFilter', value);
|
||||
this.closeDropdown();
|
||||
},
|
||||
toggleDropdown() {
|
||||
this.showActionsDropdown = !this.showActionsDropdown;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showActionsDropdown = false;
|
||||
},
|
||||
onChangeFilter(value, type) {
|
||||
this.$emit('changeFilter', value, type);
|
||||
this.saveSelectedFilter(type, value);
|
||||
},
|
||||
saveSelectedFilter(type, value) {
|
||||
this.updateUISettings({
|
||||
conversations_filter_by: {
|
||||
status: type === 'status' ? value : this.chatStatus,
|
||||
order_by: type === 'sort' ? value : this.sortFilter,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
@@ -99,39 +138,39 @@ export default {
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
@click="toggleDropdown"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="mt-1 dropdown-pane dropdown-pane--open !w-52 !p-4 top-6 border !border-n-weak dark:!border-n-weak !bg-n-alpha-3 dark:!bg-n-alpha-3 backdrop-blur-[100px]"
|
||||
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">
|
||||
<span class="text-xs font-medium text-n-slate-12">{{
|
||||
$t('CHAT_LIST.CHAT_SORT.STATUS')
|
||||
}}</span>
|
||||
<FilterItem
|
||||
type="status"
|
||||
:selected-value="chatStatus"
|
||||
:items="chatStatusItems"
|
||||
path-prefix="CHAT_LIST.CHAT_STATUS_FILTER_ITEMS"
|
||||
@on-change-filter="onChangeFilter"
|
||||
<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">
|
||||
<span class="text-xs font-medium text-n-slate-12">{{
|
||||
$t('CHAT_LIST.CHAT_SORT.ORDER_BY')
|
||||
}}</span>
|
||||
<FilterItem
|
||||
type="sort"
|
||||
:selected-value="sortFilter"
|
||||
:items="chatSortItems"
|
||||
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
|
||||
@on-change-filter="onChangeFilter"
|
||||
<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>
|
||||
|
||||
@@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
|
||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||
import EmptyState from './EmptyState/EmptyState.vue';
|
||||
import MessagesView from './MessagesView.vue';
|
||||
import ConversationSidebar from './ConversationSidebar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationSidebar,
|
||||
ConversationHeader,
|
||||
DashboardAppFrame,
|
||||
EmptyState,
|
||||
MessagesView,
|
||||
},
|
||||
|
||||
props: {
|
||||
inboxId: {
|
||||
type: [Number, String],
|
||||
@@ -34,7 +31,6 @@ export default {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
data() {
|
||||
return { activeIndex: 0 };
|
||||
},
|
||||
@@ -86,9 +82,6 @@ export default {
|
||||
}
|
||||
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.$emit('contactPanelToggle');
|
||||
},
|
||||
onDashboardAppTabChange(index) {
|
||||
this.activeIndex = index;
|
||||
},
|
||||
@@ -98,7 +91,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation-details-wrap bg-n-background"
|
||||
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-background relative"
|
||||
:class="{
|
||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||
}"
|
||||
@@ -106,15 +99,12 @@ export default {
|
||||
<ConversationHeader
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
:index="activeIndex"
|
||||
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
|
||||
class="-mt-px border-t border-t-n-background"
|
||||
@change="onDashboardAppTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
@@ -123,6 +113,8 @@ export default {
|
||||
:index="tab.index"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
class="[&_a]:pt-1"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
|
||||
@@ -130,18 +122,12 @@ export default {
|
||||
v-if="currentChat.id"
|
||||
:inbox-id="inboxId"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!currentChat.id && !isInboxView"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
/>
|
||||
<ConversationSidebar
|
||||
v-if="showContactPanel"
|
||||
:current-chat="currentChat"
|
||||
@toggle-contact-panel="onToggleContactPanel"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
<DashboardAppFrame
|
||||
v-for="(dashboardApp, index) in dashboardApps"
|
||||
@@ -154,19 +140,3 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation-details-wrap {
|
||||
@apply flex flex-col min-w-0 w-full;
|
||||
}
|
||||
|
||||
.dashboard-app--tabs {
|
||||
::v-deep {
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply pb-2 pt-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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 Thumbnail from '../Thumbnail.vue';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
@@ -14,281 +14,295 @@ import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CardLabels,
|
||||
InboxName,
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
TimeAgo,
|
||||
MessagePreview,
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
ContextMenu,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
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,
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
hovered: false,
|
||||
showContextMenu: false,
|
||||
contextMenu: {
|
||||
x: null,
|
||||
y: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
activeInbox: 'getSelectedInbox',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
bulkActionCheck() {
|
||||
return !this.hideThumbnail && !this.hovered && !this.selected;
|
||||
},
|
||||
chatMetadata() {
|
||||
return this.chat.meta || {};
|
||||
},
|
||||
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: () => [] },
|
||||
});
|
||||
|
||||
assignee() {
|
||||
return this.chatMetadata.assignee || {};
|
||||
},
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.chatMetadata.sender.id
|
||||
);
|
||||
},
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
isActiveChat() {
|
||||
return this.currentChat.id === this.chat.id;
|
||||
},
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
|
||||
unreadCount() {
|
||||
return this.chat.unread_count;
|
||||
},
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
hasUnread() {
|
||||
return this.unreadCount > 0;
|
||||
},
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
isInboxNameVisible() {
|
||||
return !this.activeInbox;
|
||||
},
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
|
||||
lastMessageInChat() {
|
||||
return getLastMessage(this.chat);
|
||||
},
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
return stateInbox;
|
||||
},
|
||||
const currentContact = computed(() => {
|
||||
return senderId.value
|
||||
? store.getters['contacts/getContact'](senderId.value)
|
||||
: {};
|
||||
});
|
||||
|
||||
showInboxName() {
|
||||
return (
|
||||
!this.hideInboxName &&
|
||||
this.isInboxNameVisible &&
|
||||
this.inboxesList.length > 1
|
||||
);
|
||||
},
|
||||
inboxName() {
|
||||
const stateInbox = this.inbox;
|
||||
return stateInbox.name || '';
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
id: chat.id,
|
||||
label: this.activeLabel,
|
||||
teamId: this.teamId,
|
||||
foldersId: this.foldersId,
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
const isActiveChat = computed(() => {
|
||||
return currentChat.value.id === props.chat.id;
|
||||
});
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
onCardHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
},
|
||||
onCardLeave() {
|
||||
this.hovered = false;
|
||||
},
|
||||
onSelectConversation(checked) {
|
||||
const action = checked ? 'selectConversation' : 'deSelectConversation';
|
||||
this.$emit(action, this.chat.id, this.inbox.id);
|
||||
},
|
||||
openContextMenu(e) {
|
||||
if (!this.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
this.$emit('contextMenuToggle', true);
|
||||
this.contextMenu.x = e.pageX || e.clientX;
|
||||
this.contextMenu.y = e.pageY || e.clientY;
|
||||
this.showContextMenu = true;
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.$emit('contextMenuToggle', false);
|
||||
this.showContextMenu = false;
|
||||
this.contextMenu.x = null;
|
||||
this.contextMenu.y = null;
|
||||
},
|
||||
onUpdateConversation(status, snoozedUntil) {
|
||||
this.closeContextMenu();
|
||||
this.$emit(
|
||||
'updateConversationStatus',
|
||||
this.chat.id,
|
||||
status,
|
||||
snoozedUntil
|
||||
);
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
this.$emit('assignAgent', agent, [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignLabel(label) {
|
||||
this.$emit('assignLabel', [label.title], [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignTeam(team) {
|
||||
this.$emit('assignTeam', team, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async markAsUnread() {
|
||||
this.$emit('markAsUnread', this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async assignPriority(priority) {
|
||||
this.$emit('assignPriority', priority, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
},
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
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]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
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 px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
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-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||
isActiveChat,
|
||||
'unread-chat': hasUnread,
|
||||
'has-inbox-name': showInboxName,
|
||||
'conversation-selected': selected,
|
||||
'bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<Thumbnail
|
||||
v-if="bulkActionCheck"
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="40px"
|
||||
/>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<div class="flex justify-between conversation-card--meta">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" />
|
||||
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:inbox="inbox"
|
||||
: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 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
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" />
|
||||
<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 w-[calc(100%-70px)] text-n-slate-12"
|
||||
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 }}
|
||||
@@ -296,25 +310,26 @@ export default {
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
:message="lastMessageInChat"
|
||||
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
|
||||
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>
|
||||
<span class="mx-0.5">
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="absolute flex flex-col conversation--meta ltr:right-4 rtl:left-4 top-4"
|
||||
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
|
||||
@@ -323,12 +338,17 @@ export default {
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ml-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
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 :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
|
||||
<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>
|
||||
@@ -346,65 +366,18 @@ export default {
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation {
|
||||
&.unread-chat {
|
||||
.unread {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
@apply pl-0;
|
||||
|
||||
.conversation-card--meta {
|
||||
@apply ltr:pr-4 rtl:pl-4;
|
||||
}
|
||||
|
||||
.conversation--details {
|
||||
@apply rounded-sm ml-0 pl-5 pr-2;
|
||||
}
|
||||
}
|
||||
|
||||
&::v-deep .user-thumbnail-box {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
&.conversation-selected {
|
||||
@apply bg-n-slate-2 dark:bg-n-slate-3;
|
||||
}
|
||||
|
||||
&.has-inbox-name {
|
||||
&::v-deep .user-thumbnail-box {
|
||||
@apply mt-8;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
@apply mt-8;
|
||||
}
|
||||
|
||||
.conversation--meta {
|
||||
@apply mt-4;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4 hover:bg-woot-100 dark:hover:bg-woot-800;
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply m-0 cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,215 +1,157 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
<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 inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.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 { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BackButton,
|
||||
InboxName,
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
setup(props, { emit }) {
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: () => emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
appIntegrations: 'integrations/getAppIntegrations',
|
||||
}),
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
},
|
||||
backButtonUrl() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||
});
|
||||
},
|
||||
isHMACVerified() {
|
||||
if (!this.isAWebWidgetInbox) {
|
||||
return true;
|
||||
}
|
||||
return this.chatMetadata.hmac_verified;
|
||||
},
|
||||
currentContact() {
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.chat.meta.sender.id
|
||||
);
|
||||
},
|
||||
isSnoozed() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
|
||||
},
|
||||
snoozedDisplayText() {
|
||||
const { snoozed_until: snoozedUntil } = this.currentChat;
|
||||
if (snoozedUntil) {
|
||||
return `${this.$t(
|
||||
'CONVERSATION.HEADER.SNOOZED_UNTIL'
|
||||
)} ${snoozedReopenTime(snoozedUntil)}`;
|
||||
}
|
||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||
},
|
||||
contactPanelToggleText() {
|
||||
return `${
|
||||
this.isContactPanelOpen
|
||||
? this.$t('CONVERSATION.HEADER.CLOSE')
|
||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
||||
},
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
},
|
||||
hasMultipleInboxes() {
|
||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
||||
},
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
isLinearIntegrationEnabled() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||
);
|
||||
},
|
||||
isLinearFeatureEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
|
||||
ref="conversationHeader"
|
||||
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
||||
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
|
||||
<BackButton
|
||||
v-if="showBackButton"
|
||||
:back-url="backButtonUrl"
|
||||
class="ltr:mr-2 rtl:ml-2"
|
||||
/>
|
||||
<Thumbnail
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
||||
<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"
|
||||
>
|
||||
<woot-button
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
class="[&>span]:overflow-hidden [&>span]:whitespace-nowrap [&>span]:text-ellipsis min-w-0"
|
||||
@click.prevent="$emit('contactPanelToggle')"
|
||||
>
|
||||
<span class="text-base font-medium leading-tight text-n-slate-12">
|
||||
{{ currentContact.name }}
|
||||
</span>
|
||||
</woot-button>
|
||||
<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]"
|
||||
icon="warning"
|
||||
/>
|
||||
</div>
|
||||
{{ 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" />
|
||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||
{{ snoozedDisplayText }}
|
||||
</span>
|
||||
<woot-button
|
||||
class="p-0"
|
||||
size="small"
|
||||
variant="link"
|
||||
@click="$emit('contactPanelToggle')"
|
||||
>
|
||||
{{ contactPanelToggleText }}
|
||||
</woot-button>
|
||||
</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
|
||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<Linear
|
||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conversation--header--actions {
|
||||
::v-deep .inbox--name {
|
||||
@apply m-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,60 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||
import { computed } from 'vue';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
currentChat: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleContactPanel']);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { t } = useI18n();
|
||||
const activeTab = computed(() => {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||
|
||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'CONTACT', value: 'contact' },
|
||||
{ key: 'COPILOT', value: 'copilot' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
|
||||
value: tab.value,
|
||||
}));
|
||||
if (isContactSidebarOpen) {
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const activeTab = ref(0);
|
||||
const toggleContactPanel = () => {
|
||||
emit('toggleContactPanel');
|
||||
};
|
||||
|
||||
const handleTabChange = selectedTab => {
|
||||
activeTab.value = tabs.value.findIndex(
|
||||
tabItem => tabItem.value === selectedTab.value
|
||||
);
|
||||
};
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const showCopilotTab = computed(() =>
|
||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||
);
|
||||
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
|
||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[320px] w-[320px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
||||
v-on-click-outside="() => closeContactPanel()"
|
||||
class="bg-n-background 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 v-if="showCopilotTab" class="p-2">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTab"
|
||||
class="w-full [&>button]:w-full"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<ContactPanel
|
||||
v-if="!activeTab"
|
||||
v-show="activeTab === 0"
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
:on-toggle="toggleContactPanel"
|
||||
/>
|
||||
<CopilotContainer
|
||||
v-else-if="activeTab === 1 && showCopilotTab"
|
||||
:key="currentChat.id"
|
||||
:conversation-inbox-type="channelType"
|
||||
:conversation-id="currentChat.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
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,
|
||||
@@ -153,13 +157,18 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-submit-button
|
||||
:button-text="$t('EMAIL_TRANSCRIPT.SUBMIT')"
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('EMAIL_TRANSCRIPT.CANCEL')"
|
||||
@click.prevent="onCancel"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('EMAIL_TRANSCRIPT.SUBMIT')"
|
||||
:disabled="!isFormValid"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="onCancel">
|
||||
{{ $t('EMAIL_TRANSCRIPT.CANCEL') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,7 @@ export default {
|
||||
src="dashboard/assets/images/no-chat.svg"
|
||||
alt="No Chat"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-slate-800 dark:text-slate-200 font-medium text-center"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12 font-medium text-center">
|
||||
{{ message }}
|
||||
<br />
|
||||
</span>
|
||||
|
||||
@@ -1,788 +0,0 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import BubbleActions from './bubble/Actions.vue';
|
||||
import BubbleContact from './bubble/Contact.vue';
|
||||
import BubbleFile from './bubble/File.vue';
|
||||
import BubbleImageAudioVideo from './bubble/ImageAudioVideo.vue';
|
||||
import BubbleIntegration from './bubble/Integration.vue';
|
||||
import BubbleLocation from './bubble/Location.vue';
|
||||
import BubbleMailHead from './bubble/MailHead.vue';
|
||||
import BubbleReplyTo from './bubble/ReplyTo.vue';
|
||||
import BubbleText from './bubble/Text.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import InstagramStory from './bubble/InstagramStory.vue';
|
||||
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { CONTENT_TYPES } from 'shared/constants/contentType';
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BubbleActions,
|
||||
BubbleContact,
|
||||
BubbleFile,
|
||||
BubbleImageAudioVideo,
|
||||
BubbleIntegration,
|
||||
BubbleLocation,
|
||||
BubbleMailHead,
|
||||
BubbleReplyTo,
|
||||
BubbleText,
|
||||
ContextMenu,
|
||||
InstagramStory,
|
||||
InstagramStoryReply,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isATweet: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAFacebookInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInstagram: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAnEmailInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxSupportsReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
inReplyTo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showContextMenu: false,
|
||||
hasMediaLoadError: false,
|
||||
contextMenuPosition: {},
|
||||
showBackgroundHighlight: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
attachments() {
|
||||
// Here it is used to get sender and created_at for each attachment
|
||||
return this.data?.attachments.map(attachment => ({
|
||||
...attachment,
|
||||
sender: this.data.sender || {},
|
||||
created_at: this.data.created_at || '',
|
||||
}));
|
||||
},
|
||||
hasOneDayPassed() {
|
||||
// Disable retry button if the message is failed and the message is older than 24 hours
|
||||
return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1;
|
||||
},
|
||||
shouldRenderMessage() {
|
||||
return (
|
||||
this.hasAttachments ||
|
||||
this.data.content ||
|
||||
this.isEmailContentType ||
|
||||
this.isUnsupported ||
|
||||
this.isAnIntegrationMessage
|
||||
);
|
||||
},
|
||||
emailMessageContent() {
|
||||
const {
|
||||
html_content: { full: fullHTMLContent } = {},
|
||||
text_content: { full: fullTextContent } = {},
|
||||
} = this.contentAttributes.email || {};
|
||||
|
||||
if (fullHTMLContent) {
|
||||
return fullHTMLContent;
|
||||
}
|
||||
|
||||
if (fullTextContent) {
|
||||
return fullTextContent.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
displayQuotedButton() {
|
||||
if (this.emailMessageContent.includes('<blockquote')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.isIncoming) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
message() {
|
||||
// If the message is an email, emailMessageContent would be present
|
||||
// In that case, we would use letter package to render the email
|
||||
if (this.emailMessageContent && this.isIncoming) {
|
||||
return this.emailMessageContent;
|
||||
}
|
||||
|
||||
const botMessageContent = generateBotMessageContent(
|
||||
this.contentType,
|
||||
this.contentAttributes,
|
||||
{
|
||||
noResponseText: this.$t('CONVERSATION.NO_RESPONSE'),
|
||||
csat: {
|
||||
ratingTitle: this.$t('CONVERSATION.RATING_TITLE'),
|
||||
feedbackTitle: this.$t('CONVERSATION.FEEDBACK_TITLE'),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (this.contentType === 'input_csat') {
|
||||
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE') + botMessageContent;
|
||||
}
|
||||
|
||||
return (
|
||||
this.formatMessage(
|
||||
this.data.content,
|
||||
this.isATweet,
|
||||
this.data.private
|
||||
) + botMessageContent
|
||||
);
|
||||
},
|
||||
inReplyToMessageId() {
|
||||
// Why not use the inReplyTo object directly?
|
||||
// Glad you asked! The inReplyTo object may or may not be available
|
||||
// depending on the current scroll position of the message list
|
||||
// since old messages are only loaded when the user scrolls up
|
||||
return this.data.content_attributes?.in_reply_to;
|
||||
},
|
||||
isAnInstagramStory() {
|
||||
return this.contentAttributes.image_type === 'story_mention';
|
||||
},
|
||||
contextMenuEnabledOptions() {
|
||||
return {
|
||||
copy: this.hasText,
|
||||
delete: this.hasText || this.hasAttachments,
|
||||
cannedResponse: this.isOutgoing && this.hasText,
|
||||
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
|
||||
};
|
||||
},
|
||||
contentAttributes() {
|
||||
return this.data.content_attributes || {};
|
||||
},
|
||||
externalError() {
|
||||
return this.contentAttributes.external_error || '';
|
||||
},
|
||||
sender() {
|
||||
return this.data.sender || {};
|
||||
},
|
||||
status() {
|
||||
return this.data.status;
|
||||
},
|
||||
storySender() {
|
||||
return this.contentAttributes.story_sender || null;
|
||||
},
|
||||
storyId() {
|
||||
return this.contentAttributes.story_id || null;
|
||||
},
|
||||
storyUrl() {
|
||||
return this.contentAttributes.story_url || null;
|
||||
},
|
||||
contentType() {
|
||||
const {
|
||||
data: { content_type: contentType },
|
||||
} = this;
|
||||
return contentType;
|
||||
},
|
||||
twitterProfileLink() {
|
||||
const additionalAttributes = this.sender.additional_attributes || {};
|
||||
const { screen_name: screenName } = additionalAttributes;
|
||||
return `https://twitter.com/${screenName}`;
|
||||
},
|
||||
alignBubble() {
|
||||
const { message_type: messageType } = this.data;
|
||||
const isCentered = messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
const isLeftAligned = messageType === MESSAGE_TYPE.INCOMING;
|
||||
const isRightAligned =
|
||||
messageType === MESSAGE_TYPE.OUTGOING ||
|
||||
messageType === MESSAGE_TYPE.TEMPLATE;
|
||||
return {
|
||||
center: isCentered,
|
||||
left: isLeftAligned,
|
||||
right: isRightAligned,
|
||||
'has-context-menu': this.showContextMenu,
|
||||
// this handles the offset required to align the context menu button
|
||||
// extra alignment is required since a tweet message has a the user name and avatar below it
|
||||
'has-tweet-menu': this.isATweet,
|
||||
'has-bg': this.showBackgroundHighlight,
|
||||
};
|
||||
},
|
||||
createdAt() {
|
||||
return this.contentAttributes.external_created_at || this.data.created_at;
|
||||
},
|
||||
isBubble() {
|
||||
return [0, 1, 3].includes(this.data.message_type);
|
||||
},
|
||||
isIncoming() {
|
||||
return this.data.message_type === MESSAGE_TYPE.INCOMING;
|
||||
},
|
||||
isOutgoing() {
|
||||
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
|
||||
},
|
||||
isTemplate() {
|
||||
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
|
||||
},
|
||||
isAnIntegrationMessage() {
|
||||
return this.contentType === 'integrations';
|
||||
},
|
||||
emailHeadAttributes() {
|
||||
return {
|
||||
email: this.contentAttributes.email,
|
||||
cc: this.contentAttributes.cc_emails,
|
||||
bcc: this.contentAttributes.bcc_emails,
|
||||
};
|
||||
},
|
||||
hasAttachments() {
|
||||
return !!(this.data.attachments && this.data.attachments.length > 0);
|
||||
},
|
||||
isMessageDeleted() {
|
||||
return this.contentAttributes.deleted;
|
||||
},
|
||||
hasText() {
|
||||
return !!this.data.content;
|
||||
},
|
||||
tooltipForSender() {
|
||||
const name = this.senderNameForAvatar;
|
||||
const { message_type: messageType } = this.data;
|
||||
const showTooltip =
|
||||
messageType === MESSAGE_TYPE.OUTGOING ||
|
||||
messageType === MESSAGE_TYPE.TEMPLATE;
|
||||
return showTooltip
|
||||
? {
|
||||
content: `${this.$t('CONVERSATION.SENT_BY')} ${name}`,
|
||||
}
|
||||
: false;
|
||||
},
|
||||
errorMessageTooltip() {
|
||||
if (this.isFailed) {
|
||||
return this.externalError || this.$t(`CONVERSATION.SEND_FAILED`);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
wrapClass() {
|
||||
return {
|
||||
wrap: this.isBubble,
|
||||
'activity-wrap': !this.isBubble,
|
||||
'is-pending': this.isPending,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
bubbleClass() {
|
||||
return {
|
||||
bubble: this.isBubble,
|
||||
'is-private': this.data.private,
|
||||
'is-unsupported': this.isUnsupported,
|
||||
'is-image': this.hasMediaAttachment('image'),
|
||||
'is-video': this.hasMediaAttachment('video'),
|
||||
'is-text': this.hasText,
|
||||
'is-from-bot': this.isSentByBot,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
isUnsupported() {
|
||||
return this.contentAttributes.is_unsupported ?? false;
|
||||
},
|
||||
isPending() {
|
||||
return this.data.status === MESSAGE_STATUS.PROGRESS;
|
||||
},
|
||||
isFailed() {
|
||||
return this.data.status === MESSAGE_STATUS.FAILED;
|
||||
},
|
||||
isSentByBot() {
|
||||
if (this.isPending || this.isFailed) return false;
|
||||
return !this.sender.type || this.sender.type === 'agent_bot';
|
||||
},
|
||||
shouldShowContextMenu() {
|
||||
return !(this.isFailed || this.isPending || this.isUnsupported);
|
||||
},
|
||||
showAvatar() {
|
||||
if (this.isOutgoing || this.isTemplate) {
|
||||
return true;
|
||||
}
|
||||
return this.isATweet && this.isIncoming && this.sender;
|
||||
},
|
||||
senderNameForAvatar() {
|
||||
if (this.isOutgoing || this.isTemplate) {
|
||||
const { name = this.$t('CONVERSATION.BOT') } = this.sender || {};
|
||||
return name;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isEmailContentType() {
|
||||
return this.contentType === CONTENT_TYPES.INCOMING_EMAIL;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
data() {
|
||||
this.hasMediaLoadError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasMediaLoadError = false;
|
||||
emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||
this.setupHighlightTimer();
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
|
||||
clearTimeout(this.higlightTimeout);
|
||||
},
|
||||
methods: {
|
||||
isAttachmentImageVideoAudio(fileType) {
|
||||
return ['image', 'audio', 'video', 'story_mention', 'ig_reel'].includes(
|
||||
fileType
|
||||
);
|
||||
},
|
||||
hasMediaAttachment(type) {
|
||||
if (this.hasAttachments && this.data.attachments.length > 0) {
|
||||
return this.compareMessageFileType(this.data, type);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
compareMessageFileType(messageData, type) {
|
||||
try {
|
||||
const { attachments = [{}] } = messageData;
|
||||
const { file_type: fileType } = attachments[0];
|
||||
return fileType === type && !this.hasMediaLoadError;
|
||||
} catch (err) {
|
||||
Sentry.setContext('attachment-parsing-error', {
|
||||
messageData,
|
||||
type,
|
||||
hasMediaLoadError: this.hasMediaLoadError,
|
||||
});
|
||||
|
||||
Sentry.captureException(err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
handleContextMenuClick() {
|
||||
this.showContextMenu = !this.showContextMenu;
|
||||
},
|
||||
async retrySendMessage() {
|
||||
await this.$store.dispatch('sendMessageWithData', this.data);
|
||||
},
|
||||
onMediaLoadError() {
|
||||
this.hasMediaLoadError = true;
|
||||
},
|
||||
openContextMenu(e) {
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
e.target?.tagName.toLowerCase() === 'a';
|
||||
if (shouldSkipContextMenu || getSelection().toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (e.type === 'contextmenu') {
|
||||
useTrack(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
|
||||
}
|
||||
this.contextMenuPosition = {
|
||||
x: e.pageX || e.clientX,
|
||||
y: e.pageY || e.clientY,
|
||||
};
|
||||
this.showContextMenu = true;
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.showContextMenu = false;
|
||||
this.contextMenuPosition = { x: null, y: null };
|
||||
},
|
||||
handleReplyTo() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
const { conversation_id: conversationId, id: replyTo } = this.data;
|
||||
|
||||
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
|
||||
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
|
||||
},
|
||||
setupHighlightTimer() {
|
||||
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showBackgroundHighlight = true;
|
||||
const HIGHLIGHT_TIMER = 1000;
|
||||
this.higlightTimeout = setTimeout(() => {
|
||||
this.showBackgroundHighlight = false;
|
||||
}, HIGHLIGHT_TIMER);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<li
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${data.id}`"
|
||||
class="group/context-menu"
|
||||
:class="[alignBubble]"
|
||||
>
|
||||
<div :class="wrapClass">
|
||||
<div
|
||||
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
|
||||
class="message-failed--alert"
|
||||
>
|
||||
<woot-button
|
||||
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
|
||||
size="tiny"
|
||||
color-scheme="alert"
|
||||
variant="clear"
|
||||
icon="arrow-clockwise"
|
||||
@click="retrySendMessage"
|
||||
/>
|
||||
</div>
|
||||
<div :class="bubbleClass" @contextmenu="openContextMenu($event)">
|
||||
<BubbleMailHead
|
||||
:email-attributes="contentAttributes.email"
|
||||
:cc="emailHeadAttributes.cc"
|
||||
:bcc="emailHeadAttributes.bcc"
|
||||
:is-incoming="isIncoming"
|
||||
/>
|
||||
<InstagramStoryReply v-if="storyUrl" :story-url="storyUrl" />
|
||||
<BubbleReplyTo
|
||||
v-if="inReplyToMessageId && inboxSupportsReplyTo.incoming"
|
||||
:message="inReplyTo"
|
||||
:message-type="data.message_type"
|
||||
:parent-has-attachments="hasAttachments"
|
||||
/>
|
||||
<div v-if="isUnsupported">
|
||||
<template v-if="isAFacebookInbox && isInstagram">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM') }}
|
||||
</template>
|
||||
<template v-else-if="isAFacebookInbox">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
||||
</template>
|
||||
</div>
|
||||
<BubbleText
|
||||
v-else-if="data.content"
|
||||
:message="message"
|
||||
:is-email="isEmailContentType"
|
||||
:display-quoted-button="displayQuotedButton"
|
||||
/>
|
||||
<BubbleIntegration
|
||||
:message-id="data.id"
|
||||
:content-attributes="contentAttributes"
|
||||
:inbox-id="data.inbox_id"
|
||||
/>
|
||||
<span
|
||||
v-if="isPending && hasAttachments"
|
||||
class="chat-bubble has-attachment agent"
|
||||
>
|
||||
{{ $t('CONVERSATION.UPLOADING_ATTACHMENTS') }}
|
||||
</span>
|
||||
<div v-if="!isPending && hasAttachments">
|
||||
<div v-for="attachment in attachments" :key="attachment.id">
|
||||
<InstagramStory
|
||||
v-if="isAnInstagramStory"
|
||||
:story-url="attachment.data_url"
|
||||
@error="onMediaLoadError"
|
||||
/>
|
||||
<BubbleImageAudioVideo
|
||||
v-else-if="isAttachmentImageVideoAudio(attachment.file_type)"
|
||||
:attachment="attachment"
|
||||
@error="onMediaLoadError"
|
||||
/>
|
||||
<BubbleLocation
|
||||
v-else-if="attachment.file_type === 'location'"
|
||||
:latitude="attachment.coordinates_lat"
|
||||
:longitude="attachment.coordinates_long"
|
||||
:name="attachment.fallback_title"
|
||||
/>
|
||||
<BubbleContact
|
||||
v-else-if="attachment.file_type === 'contact'"
|
||||
:name="data.content"
|
||||
:phone-number="attachment.fallback_title"
|
||||
/>
|
||||
<BubbleFile v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
<BubbleActions
|
||||
:id="data.id"
|
||||
:sender="data.sender"
|
||||
:story-sender="storySender"
|
||||
:external-error="errorMessageTooltip"
|
||||
:story-id="`${storyId}`"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-email="isEmailContentType"
|
||||
:is-private="data.private"
|
||||
:message-type="data.message_type"
|
||||
:message-status="status"
|
||||
:source-id="data.source_id"
|
||||
:inbox-id="data.inbox_id"
|
||||
:created-at="createdAt"
|
||||
/>
|
||||
</div>
|
||||
<Spinner v-if="isPending" size="tiny" />
|
||||
<div
|
||||
v-if="showAvatar"
|
||||
v-tooltip.left="tooltipForSender"
|
||||
class="sender--info"
|
||||
>
|
||||
<woot-thumbnail
|
||||
:src="sender.thumbnail"
|
||||
:username="senderNameForAvatar"
|
||||
size="16px"
|
||||
/>
|
||||
<a
|
||||
v-if="isATweet && isIncoming"
|
||||
class="sender--available-name"
|
||||
:href="twitterProfileLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{{ sender.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||
<ContextMenu
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
:is-open="showContextMenu"
|
||||
:enabled-options="contextMenuEnabledOptions"
|
||||
:message="data"
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@reply-to="handleReplyTo"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.wrap {
|
||||
> .bubble {
|
||||
@apply min-w-[128px];
|
||||
|
||||
&.is-unsupported {
|
||||
@apply text-xs max-w-[300px] border-dashed border border-slate-200 text-slate-600 dark:text-slate-200 bg-slate-50 dark:bg-slate-700 dark:border-slate-500;
|
||||
|
||||
.message-text--metadata .time {
|
||||
@apply text-slate-400 dark:text-slate-300;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image,
|
||||
&.is-video {
|
||||
@apply p-0 overflow-hidden;
|
||||
|
||||
.image,
|
||||
.video {
|
||||
@apply max-w-[20rem] p-0.5;
|
||||
|
||||
> img,
|
||||
> video {
|
||||
/** ensure that the bubble radius and image radius match*/
|
||||
@apply rounded-[0.4rem];
|
||||
}
|
||||
|
||||
> video {
|
||||
@apply h-full w-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
@apply h-[11.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image.is-text > .message-text__wrap,
|
||||
&.is-video.is-text > .message-text__wrap {
|
||||
@apply max-w-[20rem] py-2 px-4;
|
||||
}
|
||||
|
||||
&.is-private .file.message-text__wrap {
|
||||
.file--icon {
|
||||
@apply text-woot-400 dark:text-woot-400;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
@apply text-slate-700 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.download.button {
|
||||
@apply text-woot-400 dark:text-woot-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-private.is-text > .message-text__wrap .link {
|
||||
@apply text-woot-600 dark:text-woot-200;
|
||||
}
|
||||
|
||||
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
|
||||
@apply font-bold bg-none rounded-sm p-0 bg-yellow-100 dark:bg-yellow-700 text-slate-700 dark:text-slate-25 underline;
|
||||
}
|
||||
|
||||
&.is-from-bot {
|
||||
@apply bg-violet-400 dark:bg-violet-400;
|
||||
|
||||
.message-text--metadata .time {
|
||||
@apply text-violet-50 dark:text-violet-50;
|
||||
}
|
||||
|
||||
&.is-private .message-text--metadata .time {
|
||||
@apply text-slate-400 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-failed {
|
||||
@apply bg-red-200 dark:bg-red-200;
|
||||
|
||||
.message-text--metadata .time {
|
||||
@apply text-red-50 dark:text-red-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
@apply relative opacity-80;
|
||||
|
||||
.spinner {
|
||||
@apply absolute bottom-1 right-1;
|
||||
}
|
||||
|
||||
> .is-image.is-text.bubble > .message-text__wrap {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrap.is-email {
|
||||
--bubble-max-width: 84% !important;
|
||||
}
|
||||
|
||||
.sender--info {
|
||||
@apply items-center text-black-700 dark:text-black-100 inline-flex py-1 px-0;
|
||||
|
||||
.sender--available-name {
|
||||
@apply text-xs ml-1;
|
||||
}
|
||||
}
|
||||
|
||||
.message-failed--alert {
|
||||
@apply text-red-900 dark:text-red-900 flex-grow text-right mt-1 mr-1 mb-0 ml-0;
|
||||
}
|
||||
|
||||
li.left,
|
||||
li.right {
|
||||
@apply flex items-end;
|
||||
}
|
||||
|
||||
li.left.has-tweet-menu .context-menu {
|
||||
// this handles the offset required to align the context menu button
|
||||
// extra alignment is required since a tweet message has a the user name and avatar below it
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
li.has-bg {
|
||||
@apply bg-woot-75 dark:bg-woot-600;
|
||||
}
|
||||
|
||||
li.right .context-menu-wrap {
|
||||
@apply ml-auto;
|
||||
}
|
||||
|
||||
li.right {
|
||||
@apply flex-row-reverse justify-end;
|
||||
|
||||
.wrap.is-pending {
|
||||
@apply ml-auto;
|
||||
}
|
||||
|
||||
.wrap.is-failed {
|
||||
@apply flex items-end ml-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.has-context-menu {
|
||||
@apply bg-slate-50 dark:bg-slate-700;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
|
||||
.bubble .text-content {
|
||||
p code {
|
||||
@apply bg-slate-75 dark:bg-slate-700 inline-block leading-none rounded-sm p-1;
|
||||
}
|
||||
|
||||
ol li {
|
||||
@apply list-item list-decimal;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-slate-75 dark:bg-slate-700 block border-slate-75 dark:border-slate-700 text-slate-800 dark:text-slate-100 rounded-md p-2 mt-1 mb-2 leading-relaxed whitespace-pre-wrap;
|
||||
|
||||
code {
|
||||
@apply bg-transparent text-slate-800 dark:text-slate-100 p-0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 mx-0 my-1 pt-2 pr-2 pb-0 pl-4 border-slate-75 border-solid dark:border-slate-600 text-slate-800 dark:text-slate-100;
|
||||
|
||||
p {
|
||||
@apply text-slate-800 dark:text-slate-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right .bubble .text-content {
|
||||
p code {
|
||||
@apply bg-woot-600 dark:bg-woot-600 text-white dark:text-white;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-woot-800 dark:bg-woot-800 border-woot-700 dark:border-woot-700 text-white dark:text-white;
|
||||
|
||||
code {
|
||||
@apply bg-transparent text-white dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 border-solid border-woot-400 dark:border-woot-400 text-white dark:text-white;
|
||||
|
||||
p {
|
||||
@apply text-woot-75 dark:text-woot-75;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -66,26 +66,26 @@ export default {
|
||||
<fluent-icon
|
||||
v-if="isMessagePrivate"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle text-slate-600 dark:text-slate-300 inline-block"
|
||||
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-slate-600 dark:text-slate-300 inline-block"
|
||||
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-slate-600 dark:text-slate-300 inline-block"
|
||||
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-slate-600 dark:text-slate-300"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
icon="image"
|
||||
/>
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
<fluent-icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-11"
|
||||
:icon="attachmentIcon"
|
||||
/>
|
||||
{{ $t(`${attachmentMessageContent}`) }}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
openProfileSettings() {
|
||||
return this.$router.push({ name: 'profile_settings_index' });
|
||||
},
|
||||
},
|
||||
<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 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-slate-25 dark:hover:bg-slate-800 border border-dashed border-slate-100 dark:border-slate-700 rounded-sm overflow-auto"
|
||||
class="my-0 mx-4 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') }}
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="link"
|
||||
|
||||
<Button
|
||||
link
|
||||
:label="$t('CONVERSATION.FOOTER.CLICK_HERE')"
|
||||
@click="openProfileSettings"
|
||||
>
|
||||
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { ref, provide } from 'vue';
|
||||
// composable
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import Message from './Message.vue';
|
||||
import NextMessageList from 'next/message/MessageList.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';
|
||||
@@ -35,43 +34,27 @@ 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 { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
NextMessageList,
|
||||
MessageList,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const closePopOutReplyBox = () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
};
|
||||
|
||||
const showPopOutReplyBox = () => {
|
||||
isPopOutReplyBox.value = !isPopOutReplyBox.value;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: closePopOutReplyBox,
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,26 +67,16 @@ export default {
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showNextBubbles = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
closePopOutReplyBox,
|
||||
showPopOutReplyBox,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
showNextBubbles,
|
||||
conversationPanelRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -186,24 +159,20 @@ export default {
|
||||
(!this.listLoadingStatus && this.isLoadingPrevious)
|
||||
);
|
||||
},
|
||||
conversationType() {
|
||||
const { additional_attributes: additionalAttributes } = this.currentChat;
|
||||
const type = additionalAttributes ? additionalAttributes.type : '';
|
||||
return type || '';
|
||||
},
|
||||
// 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
|
||||
);
|
||||
|
||||
isATweet() {
|
||||
return this.conversationType === 'tweet';
|
||||
},
|
||||
isRightOrLeftIcon() {
|
||||
if (this.isContactPanelOpen) {
|
||||
return 'arrow-chevron-right';
|
||||
}
|
||||
return 'arrow-chevron-left';
|
||||
},
|
||||
getLastSeenAt() {
|
||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt;
|
||||
return (
|
||||
this.inbox.channel_type === INBOX_TYPES.FB &&
|
||||
additionalAttributes.type === 'instagram_direct_message' &&
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
@@ -215,24 +184,37 @@ export default {
|
||||
if (additionalAttributes) {
|
||||
const {
|
||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||
agent_reply_time_window: agentReplyTimeWindow,
|
||||
} = additionalAttributes;
|
||||
return agentReplyTimeWindowMessage;
|
||||
return (
|
||||
agentReplyTimeWindowMessage ||
|
||||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
|
||||
hours: agentReplyTimeWindow,
|
||||
})
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
}
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
replyWindowLinkText() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAnInstagramChannel
|
||||
) {
|
||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
@@ -252,9 +234,6 @@ export default {
|
||||
: 'CONVERSATION.UNREAD_MESSAGE';
|
||||
return `${count} ${this.$t(label)}`;
|
||||
},
|
||||
isInstagramDM() {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
const outgoing =
|
||||
@@ -407,9 +386,6 @@ export default {
|
||||
relevantMessages
|
||||
);
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.$emit('contactPanelToggle');
|
||||
},
|
||||
setScrollParams() {
|
||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||
@@ -461,18 +437,6 @@ export default {
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
getInReplyToMessage(parentMessage) {
|
||||
if (!parentMessage) return {};
|
||||
const inReplyToMessageId = parentMessage.content_attributes?.in_reply_to;
|
||||
if (!inReplyToMessageId) return {};
|
||||
|
||||
return this.currentChat?.messages.find(message => {
|
||||
if (message.id === inReplyToMessageId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -482,25 +446,20 @@ export default {
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mt-2 mx-2 rounded-lg overflow-hidden"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<woot-button
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class="box-border fixed z-10 bg-white border border-r-0 border-solid rounded-bl-calc rtl:rotate-180 rounded-tl-calc border-n-weak"
|
||||
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
|
||||
:icon="isRightOrLeftIcon"
|
||||
@click="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<NextMessageList
|
||||
v-if="showNextBubbles"
|
||||
class="conversation-panel"
|
||||
<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"
|
||||
@@ -510,14 +469,21 @@ export default {
|
||||
<template #beforeAll>
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||
<li
|
||||
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="unread--toast">
|
||||
<span>
|
||||
<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>
|
||||
@@ -530,65 +496,12 @@ export default {
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
</template>
|
||||
</NextMessageList>
|
||||
<ul v-else class="conversation-panel">
|
||||
<transition name="slide-up">
|
||||
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
|
||||
<li class="min-h-[4rem]">
|
||||
<span v-if="shouldShowSpinner" class="spinner message" />
|
||||
</li>
|
||||
</transition>
|
||||
<Message
|
||||
v-for="message in readMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-an-email-inbox="isAnEmailChannel"
|
||||
:is-instagram="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<li v-show="unreadMessageCount != 0" class="unread--toast">
|
||||
<span>
|
||||
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
|
||||
{{
|
||||
unreadMessageCount > 1
|
||||
? $t('CONVERSATION.UNREAD_MESSAGES')
|
||||
: $t('CONVERSATION.UNREAD_MESSAGE')
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<Message
|
||||
v-for="message in unReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
:data="message"
|
||||
:is-a-tweet="isATweet"
|
||||
:is-a-whatsapp-channel="isAWhatsAppChannel"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-a-facebook-inbox="isAFacebookInbox"
|
||||
:is-instagram-dm="isInstagramDM"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
/>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
:suggested-labels="labelSuggestions"
|
||||
:chat-labels="currentChat.labels"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
</ul>
|
||||
</MessageList>
|
||||
<div
|
||||
class="conversation-footer"
|
||||
class="flex relative flex-col"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-background': showNextBubbles && !isPopOutReplyBox,
|
||||
'bg-n-background': !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -596,7 +509,7 @@ export default {
|
||||
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-slate-700 text-n-slate-11 text-xs font-semibold my-2.5 mx-auto"
|
||||
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
|
||||
@@ -607,30 +520,16 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<ReplyBox
|
||||
v-model:popout-reply-box="isPopOutReplyBox"
|
||||
@toggle-popout="showPopOutReplyBox"
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.rounded-bl-calc {
|
||||
border-bottom-left-radius: calc(1.5rem + 1px);
|
||||
}
|
||||
|
||||
.rounded-tl-calc {
|
||||
border-top-left-radius: calc(1.5rem + 1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-mask {
|
||||
@apply absolute;
|
||||
@apply fixed;
|
||||
|
||||
&::v-deep {
|
||||
.ProseMirror-woot-style {
|
||||
@@ -654,7 +553,7 @@ export default {
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@apply absolute left-auto bottom-1;
|
||||
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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,
|
||||
@@ -12,97 +16,111 @@ import {
|
||||
CMD_UNMUTE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmailTranscriptModal,
|
||||
ResolveAction,
|
||||
ButtonV4,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showEmailActionsModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
|
||||
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
},
|
||||
unmounted() {
|
||||
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
|
||||
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
},
|
||||
methods: {
|
||||
mute() {
|
||||
this.$store.dispatch('muteConversation', this.currentChat.id);
|
||||
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
|
||||
},
|
||||
unmute() {
|
||||
this.$store.dispatch('unmuteConversation', this.currentChat.id);
|
||||
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
||||
},
|
||||
toggleEmailActionsModal() {
|
||||
this.showEmailActionsModal = !this.showEmailActionsModal;
|
||||
},
|
||||
},
|
||||
// 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">
|
||||
<ButtonV4
|
||||
v-if="!currentChat.muted"
|
||||
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-volume-off"
|
||||
@click="mute"
|
||||
/>
|
||||
<ButtonV4
|
||||
v-else
|
||||
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-volume-1"
|
||||
@click="unmute"
|
||||
/>
|
||||
<ButtonV4
|
||||
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-share"
|
||||
@click="toggleEmailActionsModal"
|
||||
/>
|
||||
<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="toggleEmailActionsModal"
|
||||
@cancel="toggleEmailModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.more--button {
|
||||
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
|
||||
}
|
||||
|
||||
.dropdown-pane {
|
||||
@apply -right-2 top-12;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,24 +29,24 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
|
||||
class="h-full w-full bg-n-background 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-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
|
||||
class="text-base text-n-slate-12 font-interDisplay font-semibold tracking-[0.3px]"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-sm">
|
||||
<p class="text-n-slate-11 text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="to"
|
||||
:to="{ name: to }"
|
||||
class="no-underline text-woot-500 text-sm font-medium"
|
||||
class="no-underline text-n-brand text-sm font-medium"
|
||||
>
|
||||
<span>{{ linkText }}</span>
|
||||
<span class="ml-2">{{ `→` }}</span>
|
||||
|
||||
@@ -32,11 +32,11 @@ const greetingMessage = computed(() => {
|
||||
>
|
||||
<div class="col-span-full self-start">
|
||||
<p
|
||||
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
|
||||
class="text-xl font-semibold text-n-slate-12 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ greetingMessage }}
|
||||
</p>
|
||||
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
|
||||
<p class="text-n-slate-11 max-w-2xl text-base">
|
||||
{{
|
||||
$t('ONBOARDING.DESCRIPTION', {
|
||||
installationName: globalConfig.installationName,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
// [TODO] The popout events are needlessly complex and should be simplified
|
||||
import { defineAsyncComponent, defineModel, useTemplateRef } from 'vue';
|
||||
import { defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
@@ -66,7 +65,13 @@ export default {
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
emits: ['update:popoutReplyBox', 'togglePopout'],
|
||||
props: {
|
||||
popOutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:popOutReplyBox'],
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
@@ -75,16 +80,10 @@ export default {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
} = useUISettings();
|
||||
|
||||
const popoutReplyBox = defineModel('popoutReplyBox', {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
});
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
popoutReplyBox,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
@@ -123,7 +122,6 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentChat: 'getSelectedChat',
|
||||
messageSignature: 'getMessageSignature',
|
||||
currentUser: 'getCurrentUser',
|
||||
@@ -186,9 +184,8 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
hasWhatsappTemplates() {
|
||||
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||
.length;
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
@@ -241,15 +238,27 @@ export default {
|
||||
if (this.isAFacebookInbox) {
|
||||
return MESSAGE_MAX_LENGTH.FACEBOOK;
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (this.isASmsInbox) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAnEmailChannel) {
|
||||
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||
}
|
||||
if (this.isATwilioSMSChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||
}
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
@@ -261,7 +270,8 @@ export default {
|
||||
this.isAnEmailChannel ||
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -303,15 +313,6 @@ export default {
|
||||
this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
emojiDialogClassOnExpandedLayoutAndRTLView() {
|
||||
if (this.isOnExpandedLayout || this.popoutReplyBox) {
|
||||
return 'emoji-dialog--expanded';
|
||||
}
|
||||
if (this.isRTL) {
|
||||
return 'emoji-dialog--rtl';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isMessageEmpty() {
|
||||
if (!this.message) {
|
||||
return true;
|
||||
@@ -326,7 +327,8 @@ export default {
|
||||
this.isAnEmailChannel ||
|
||||
this.isAWebWidgetInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAWhatsAppChannel
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
isSignatureEnabledForInbox() {
|
||||
@@ -370,6 +372,7 @@ export default {
|
||||
const variables = getMessageVariables({
|
||||
conversation: this.currentChat,
|
||||
contact: this.currentContact,
|
||||
inbox: this.inbox,
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
@@ -386,10 +389,14 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
currentChat(conversation, oldConversation) {
|
||||
const { can_reply: canReply } = conversation;
|
||||
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
if (oldConversation && oldConversation.id !== conversation.id) {
|
||||
// Only update email fields when switching to a completely different conversation (by ID)
|
||||
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||
// like self-assign or other updates that do not actually change the conversation context
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
}
|
||||
|
||||
if (this.isOnPrivateNote) {
|
||||
return;
|
||||
@@ -403,10 +410,23 @@ export default {
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
},
|
||||
// When moving from one conversation to another, the store may not have the
|
||||
// list of all the messages. A fetch is subsequently made to get the messages.
|
||||
// This watcher handles two main cases:
|
||||
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages).
|
||||
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change.
|
||||
lastEmail: {
|
||||
handler(lastEmail) {
|
||||
if (!lastEmail) return;
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
conversationIdByRoute(conversationId, oldConversationId) {
|
||||
if (conversationId !== oldConversationId) {
|
||||
this.setToDraft(oldConversationId, this.replyType);
|
||||
this.getFromDraft();
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
message(updatedMessage) {
|
||||
@@ -516,6 +536,12 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
resetRecorderAndClearAttachments() {
|
||||
// Reset audio recorder UI state
|
||||
this.resetAudioRecorderInput();
|
||||
// Reset attached files
|
||||
this.attachedFiles = [];
|
||||
},
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = `draft-${conversationId}-${replyType}`;
|
||||
@@ -665,7 +691,11 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
if (isOnWhatsApp && !this.isPrivate) {
|
||||
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
@@ -678,11 +708,11 @@ export default {
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popoutReplyBox', false);
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message) {
|
||||
const messages = this.getMessagePayloadForWhatsapp(message);
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(messagePayload);
|
||||
});
|
||||
@@ -914,11 +944,11 @@ export default {
|
||||
|
||||
return payload;
|
||||
},
|
||||
getMessagePayloadForWhatsapp(message) {
|
||||
getMultipleMessagesPayload(message) {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = message;
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -933,9 +963,19 @@ export default {
|
||||
|
||||
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
||||
multipleMessagePayload.push(attachmentPayload);
|
||||
caption = '';
|
||||
// For WhatsApp, only the first attachment gets a caption
|
||||
if (!this.isAnInstagramChannel) caption = '';
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
@@ -989,45 +1029,20 @@ export default {
|
||||
this.ccEmails = value.ccEmails;
|
||||
},
|
||||
setCCAndToEmailsFromLastChat() {
|
||||
if (!this.lastEmail) return;
|
||||
|
||||
const {
|
||||
content_attributes: { email: emailAttributes = {} },
|
||||
} = this.lastEmail;
|
||||
|
||||
// Retrieve the email of the current conversation's sender
|
||||
const conversationContact = this.currentChat?.meta?.sender?.email || '';
|
||||
let cc = emailAttributes.cc ? [...emailAttributes.cc] : [];
|
||||
let to = [];
|
||||
const { email: inboxEmail, forward_to_email: forwardToEmail } =
|
||||
this.inbox;
|
||||
|
||||
// there might be a situation where the current conversation will include a message from a third person,
|
||||
// and the current conversation contact is in CC.
|
||||
// This is an edge-case, reported here: CW-1511 [ONLY FOR INTERNAL REFERENCE]
|
||||
// So we remove the current conversation contact's email from the CC list if present
|
||||
if (cc.includes(conversationContact)) {
|
||||
cc = cc.filter(email => email !== conversationContact);
|
||||
}
|
||||
|
||||
// If the last incoming message sender is different from the conversation contact, add them to the "to"
|
||||
// and add the conversation contact to the CC
|
||||
if (!emailAttributes.from.includes(conversationContact)) {
|
||||
to.push(...emailAttributes.from);
|
||||
cc.push(conversationContact);
|
||||
}
|
||||
|
||||
// Remove the conversation contact's email from the BCC list if present
|
||||
let bcc = (emailAttributes.bcc || []).filter(
|
||||
email => email !== conversationContact
|
||||
const { cc, bcc, to } = getRecipients(
|
||||
this.lastEmail,
|
||||
conversationContact,
|
||||
inboxEmail,
|
||||
forwardToEmail
|
||||
);
|
||||
|
||||
// Ensure only unique email addresses are in the CC list
|
||||
bcc = [...new Set(bcc)];
|
||||
cc = [...new Set(cc)];
|
||||
to = [...new Set(to)];
|
||||
|
||||
this.toEmails = to.join(', ');
|
||||
this.ccEmails = cc.join(', ');
|
||||
this.bccEmails = bcc.join(', ');
|
||||
this.toEmails = to.join(', ');
|
||||
},
|
||||
fetchAndSetReplyTo() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
@@ -1072,6 +1087,9 @@ export default {
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1079,9 +1097,9 @@ export default {
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="clear"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="banner--self-assign mx-2 mb-2 rounded-lg"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@@ -1092,9 +1110,9 @@ export default {
|
||||
:mode="replyType"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popoutReplyBox"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="$emit('togglePopout')"
|
||||
@toggle-popout="togglePopout"
|
||||
/>
|
||||
<ArticleSearchPopover
|
||||
v-if="showArticleSearchPopover && connectedPortalSlug"
|
||||
@@ -1118,7 +1136,9 @@ export default {
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="emojiDialogClassOnExpandedLayoutAndRTLView"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
@@ -1140,7 +1160,7 @@ export default {
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
@@ -1192,7 +1212,7 @@ export default {
|
||||
<ReplyBottomPanel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:has-whatsapp-templates="hasWhatsappTemplates"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1200,6 +1220,7 @@ export default {
|
||||
:mode="replyType"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
@@ -1265,26 +1286,16 @@ export default {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
|
||||
textarea {
|
||||
@apply shadow-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@apply top-[unset] -bottom-10 -left-80 right-[unset];
|
||||
@apply top-[unset] -bottom-10 ltr:-left-80 ltr:right-[unset] rtl:left-[unset] rtl:-right-80;
|
||||
|
||||
&::before {
|
||||
transform: rotate(270deg);
|
||||
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||
@apply -right-4 bottom-2 rtl:right-0 rtl:-left-4;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-dialog--rtl {
|
||||
@apply left-[unset] -right-80;
|
||||
|
||||
&::before {
|
||||
transform: rotate(90deg);
|
||||
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||
@apply ltr:-right-4 bottom-2 rtl:-left-4 ltr:rotate-[270deg] rtl:rotate-[90deg];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,12 +1304,12 @@ export default {
|
||||
|
||||
&::before {
|
||||
transform: rotate(0deg);
|
||||
@apply left-1 -bottom-2;
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * var(--space-normal));
|
||||
left: var(--space-normal);
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="v$.toEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
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"
|
||||
@@ -113,7 +113,7 @@ export default {
|
||||
<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]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
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')"
|
||||
@@ -142,7 +142,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="v$.bccEmailsVal.$model"
|
||||
type="text"
|
||||
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
|
||||
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')
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { MessagePreview },
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
emits: ['dismiss'],
|
||||
};
|
||||
});
|
||||
|
||||
const emit = defineEmits(['dismiss']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
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">
|
||||
@@ -27,14 +26,13 @@ export default {
|
||||
class="inline"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
<Button
|
||||
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
size="tiny"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="$emit('dismiss')"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
@@ -13,41 +14,89 @@ const props = defineProps({
|
||||
|
||||
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(() => {
|
||||
if (!props.searchKey) {
|
||||
return agents.value;
|
||||
}
|
||||
return agents.value.filter(agent =>
|
||||
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
|
||||
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) {
|
||||
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
|
||||
const selectedElement = tagAgentsRef.value.querySelector(
|
||||
`#mention-item-${selectedIndex.value}`
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('selectAgent', items.value[selectedIndex.value]);
|
||||
emit('selectAgent', selectableItems.value[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
items: selectableItems,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(items, newListOfAgents => {
|
||||
watch(selectableItems, newListOfAgents => {
|
||||
if (newListOfAgents.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
@@ -69,44 +118,61 @@ const onAgentSelect = index => {
|
||||
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"
|
||||
:class="{
|
||||
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
|
||||
items.length <= 4,
|
||||
}"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="agent.id"
|
||||
:class="{
|
||||
'bg-n-alpha-black2': index === selectedIndex,
|
||||
'last:mb-0': items.length <= 4,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md"
|
||||
@click="onAgentSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
v-for="item in items"
|
||||
:id="
|
||||
item.type === 'header'
|
||||
? undefined
|
||||
: `mention-item-${getSelectableIndex(item)}`
|
||||
"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
>
|
||||
<div class="mr-2">
|
||||
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
|
||||
</div>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
v-if="item.type === 'header'"
|
||||
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||
>
|
||||
<h5
|
||||
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ agent.name }}
|
||||
</h5>
|
||||
{{ 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 text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': index === selectedIndex,
|
||||
}"
|
||||
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ agent.email }}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<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 {
|
||||
@@ -16,6 +17,9 @@ export default {
|
||||
...mapGetters({
|
||||
customAttributes: 'attributes/getAttributes',
|
||||
}),
|
||||
sanitizedSearchKey() {
|
||||
return sanitizeVariableSearchKey(this.searchKey);
|
||||
},
|
||||
items() {
|
||||
return [
|
||||
...this.standardAttributeVariables,
|
||||
@@ -25,8 +29,8 @@ export default {
|
||||
standardAttributeVariables() {
|
||||
return MESSAGE_VARIABLES.filter(variable => {
|
||||
return (
|
||||
variable.label.includes(this.searchKey) ||
|
||||
variable.key.includes(this.searchKey)
|
||||
variable.label.includes(this.sanitizedSearchKey) ||
|
||||
variable.key.includes(this.sanitizedSearchKey)
|
||||
);
|
||||
}).map(variable => ({
|
||||
label: variable.key,
|
||||
@@ -69,6 +73,6 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.variable--list-label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
@@ -8,184 +8,51 @@
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
emits: ['sendMessage', 'resetTemplate'],
|
||||
setup(props, { emit }) {
|
||||
const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
});
|
||||
|
||||
const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.every(key => value[key]);
|
||||
};
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
||||
|
||||
const processedParams = ref({});
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const templateString = computed(() => {
|
||||
return props.template.components.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
});
|
||||
|
||||
const variables = computed(() => {
|
||||
return templateString.value.match(/{{([^}]+)}}/g);
|
||||
});
|
||||
|
||||
const processedString = computed(() => {
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(variables),
|
||||
allKeysRequired,
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const payload = {
|
||||
message: processedString.value,
|
||||
templateParams: {
|
||||
name: props.template.name,
|
||||
category: props.template.category,
|
||||
language: props.template.language,
|
||||
namespace: props.template.namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
onMounted(generateVariables);
|
||||
|
||||
return {
|
||||
processedParams,
|
||||
variables,
|
||||
templateString,
|
||||
processedString,
|
||||
v$,
|
||||
resetTemplate,
|
||||
sendMessage,
|
||||
};
|
||||
},
|
||||
const handleResetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
v-model="processedString"
|
||||
rows="4"
|
||||
readonly
|
||||
class="template-input"
|
||||
/>
|
||||
<div v-if="variables" class="template__variables-container">
|
||||
<p class="variables-label">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="template__variable-item"
|
||||
>
|
||||
<span class="variable-label">
|
||||
{{ key }}
|
||||
</span>
|
||||
<woot-input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="variable-input"
|
||||
:styles="{ marginBottom: 0 }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="v$.$dirty && v$.$invalid" class="error">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<footer>
|
||||
<woot-button variant="smooth" @click="resetTemplate">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button type="button" @click="sendMessage">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
|
||||
</woot-button>
|
||||
</footer>
|
||||
<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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template__variables-container {
|
||||
@apply p-2.5;
|
||||
}
|
||||
|
||||
.variables-label {
|
||||
@apply text-sm font-semibold mb-2.5;
|
||||
}
|
||||
|
||||
.template__variable-item {
|
||||
@apply items-center flex mb-2.5;
|
||||
|
||||
.label {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.variable-input {
|
||||
@apply flex-1 text-sm ml-2.5;
|
||||
}
|
||||
|
||||
.variable-label {
|
||||
@apply bg-slate-75 dark:bg-slate-700 text-slate-700 dark:text-slate-100 inline-block rounded-md text-xs py-2.5 px-6;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
@apply flex justify-end;
|
||||
|
||||
button {
|
||||
@apply ml-2.5;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply bg-red-100 dark:bg-red-100 rounded-md text-red-800 dark:text-red-800 p-2.5 text-center;
|
||||
}
|
||||
|
||||
.template-input {
|
||||
@apply bg-slate-25 dark:bg-slate-900 text-slate-700 dark:text-slate-100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,142 +1,212 @@
|
||||
<script>
|
||||
// TODO: Remove this when we support all formats
|
||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
||||
<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';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['onSelect'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
whatsAppTemplateMessages() {
|
||||
// TODO: Remove the last filter when we support all formats
|
||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
||||
.filter(template => template.status.toLowerCase() === 'approved')
|
||||
.filter(template => {
|
||||
return template.components.every(component => {
|
||||
return !formatsToRemove.includes(component.format);
|
||||
});
|
||||
});
|
||||
},
|
||||
filteredTemplateMessages() {
|
||||
return this.whatsAppTemplateMessages.filter(template =>
|
||||
template.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getTemplatebody(template) {
|
||||
return template.components.find(component => component.type === 'BODY')
|
||||
.text;
|
||||
},
|
||||
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="gap-1 templates__list-search">
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="templates__search-input"
|
||||
/>
|
||||
<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="template__list-container">
|
||||
<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="template__list-item"
|
||||
@click="$emit('onSelect', template)"
|
||||
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 items-center justify-between mb-2.5">
|
||||
<p class="label-title">
|
||||
<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 bg-white rounded-sm cursor-default dark:bg-slate-700 text-slate-800 dark:text-slate-100"
|
||||
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') }} :
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
|
||||
{{ template.language }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
||||
<!-- 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>
|
||||
<p class="label-body">{{ getTemplatebody(template) }}</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>
|
||||
<div class="label-category">
|
||||
<p class="strong">
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
|
||||
</p>
|
||||
<p>{{ template.category }}</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}`" />
|
||||
<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">
|
||||
<p>
|
||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
<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">
|
||||
.templates__list-search {
|
||||
@apply items-center flex bg-slate-25 dark:bg-slate-900 mb-2.5 py-0 px-2.5 rounded-md border border-solid border-slate-100 dark:border-slate-700;
|
||||
|
||||
.search-icon {
|
||||
@apply text-slate-400 dark:text-slate-300;
|
||||
}
|
||||
|
||||
.templates__search-input {
|
||||
@apply bg-transparent border-0 text-xs h-9 m-0;
|
||||
}
|
||||
}
|
||||
.template__list-container {
|
||||
@apply bg-slate-25 dark:bg-slate-900 rounded-md max-h-[18.75rem] overflow-y-auto p-2.5;
|
||||
|
||||
.template__list-item {
|
||||
@apply rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-woot-50 dark:hover:bg-slate-800;
|
||||
|
||||
.label-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.label-category {
|
||||
@apply mt-5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.strong {
|
||||
@apply text-xs font-semibold;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply border-b border-solid border-slate-100 dark:border-slate-700 my-2.5 mx-auto max-w-[95%];
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,14 @@ const filterTypes = [
|
||||
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',
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
<script>
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
export default {
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
sender: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
storySender: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
externalError: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
storyId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isEmail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPrivate: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isATweet: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
messageType: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
messageStatus: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
sourceId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
isIncoming() {
|
||||
return MESSAGE_TYPE.INCOMING === this.messageType;
|
||||
},
|
||||
isOutgoing() {
|
||||
return MESSAGE_TYPE.OUTGOING === this.messageType;
|
||||
},
|
||||
isTemplate() {
|
||||
return MESSAGE_TYPE.TEMPLATE === this.messageType;
|
||||
},
|
||||
isDelivered() {
|
||||
return MESSAGE_STATUS.DELIVERED === this.messageStatus;
|
||||
},
|
||||
isRead() {
|
||||
return MESSAGE_STATUS.READ === this.messageStatus;
|
||||
},
|
||||
isSent() {
|
||||
return MESSAGE_STATUS.SENT === this.messageStatus;
|
||||
},
|
||||
readableTime() {
|
||||
return messageTimestamp(this.createdAt, 'LLL d, h:mm a');
|
||||
},
|
||||
screenName() {
|
||||
const { additional_attributes: additionalAttributes = {} } =
|
||||
this.sender || {};
|
||||
return additionalAttributes?.screen_name || '';
|
||||
},
|
||||
linkToTweet() {
|
||||
if (!this.sourceId || !this.inbox.name) {
|
||||
return '';
|
||||
}
|
||||
const { screenName, sourceId } = this;
|
||||
return `https://twitter.com/${
|
||||
screenName || this.inbox.name
|
||||
}/status/${sourceId}`;
|
||||
},
|
||||
linkToStory() {
|
||||
if (!this.storyId || !this.storySender) {
|
||||
return '';
|
||||
}
|
||||
const { storySender, storyId } = this;
|
||||
return `https://www.instagram.com/stories/direct/${storySender}_${storyId}`;
|
||||
},
|
||||
showStatusIndicators() {
|
||||
if ((this.isOutgoing || this.isTemplate) && !this.isPrivate) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showSentIndicator() {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
// Messages will be marked as sent for the Email channel if they have a source ID.
|
||||
if (this.isAnEmailChannel) {
|
||||
return !!this.sourceId;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel
|
||||
) {
|
||||
return this.sourceId && this.isSent;
|
||||
}
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (this.isALineChannel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showDeliveredIndicator() {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
this.isASmsInbox ||
|
||||
this.isAFacebookInbox
|
||||
) {
|
||||
return this.sourceId && this.isDelivered;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (this.isAWebWidgetInbox || this.isAPIInbox) {
|
||||
return this.isSent;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return this.isDelivered;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showReadIndicator() {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
this.isAFacebookInbox
|
||||
) {
|
||||
return this.sourceId && this.isRead;
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox || this.isAPIInbox) {
|
||||
return this.isRead;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-text--metadata">
|
||||
<span
|
||||
class="time"
|
||||
:class="{
|
||||
'has-status-icon':
|
||||
showSentIndicator || showDeliveredIndicator || showReadIndicator,
|
||||
}"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
<span v-if="externalError" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="externalError"
|
||||
icon="error-circle"
|
||||
class="action--icon"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="showReadIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick read-indicator"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="showDeliveredIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
|
||||
icon="checkmark-double"
|
||||
class="action--icon read-tick"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="showSentIndicator" class="read-indicator-wrap">
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
|
||||
icon="checkmark"
|
||||
class="action--icon read-tick"
|
||||
size="14"
|
||||
/>
|
||||
</span>
|
||||
<fluent-icon
|
||||
v-if="isEmail"
|
||||
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
|
||||
icon="mail"
|
||||
class="action--icon"
|
||||
size="16"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-if="isPrivate"
|
||||
v-tooltip.top-start="$t('CONVERSATION.VISIBLE_TO_AGENTS')"
|
||||
icon="lock-closed"
|
||||
class="action--icon lock--icon--private"
|
||||
size="16"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
/>
|
||||
<a
|
||||
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
|
||||
:href="linkToTweet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<fluent-icon
|
||||
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
|
||||
icon="open"
|
||||
class="cursor-pointer action--icon"
|
||||
size="16"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.right {
|
||||
.message-text--metadata {
|
||||
@apply items-center;
|
||||
.time {
|
||||
@apply text-woot-100 dark:text-woot-100;
|
||||
}
|
||||
|
||||
.action--icon {
|
||||
@apply text-white dark:text-white;
|
||||
|
||||
&.read-tick {
|
||||
@apply text-violet-100 dark:text-violet-100;
|
||||
}
|
||||
|
||||
&.read-indicator {
|
||||
@apply text-green-200 dark:text-green-200;
|
||||
}
|
||||
}
|
||||
|
||||
.lock--icon--private {
|
||||
@apply text-slate-400 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
.message-text--metadata {
|
||||
.time {
|
||||
@apply text-slate-400 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-text--metadata {
|
||||
@apply items-start flex;
|
||||
|
||||
.time {
|
||||
@apply mr-2 block text-xxs leading-[1.8];
|
||||
}
|
||||
|
||||
.action--icon {
|
||||
@apply mr-2 ml-2 text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-wrap {
|
||||
.message-text--metadata {
|
||||
.time {
|
||||
@apply ml-2 rtl:mr-2 rtl:ml-0 flex text-center text-xxs text-slate-300 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-image,
|
||||
.is-video {
|
||||
.message-text--metadata {
|
||||
.time {
|
||||
@apply bottom-1 text-white dark:text-slate-50 absolute right-2 whitespace-nowrap;
|
||||
|
||||
&.has-status-icon {
|
||||
@apply right-8 leading-loose;
|
||||
}
|
||||
}
|
||||
.read-tick {
|
||||
@apply absolute bottom-2 right-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-private {
|
||||
.message-text--metadata {
|
||||
@apply items-center;
|
||||
|
||||
.time {
|
||||
@apply text-slate-400 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-slate-400 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-image,
|
||||
&.is-video {
|
||||
.time {
|
||||
position: inherit;
|
||||
@apply pl-2.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delivered-icon {
|
||||
@apply ml-4;
|
||||
}
|
||||
|
||||
.read-indicator-wrap {
|
||||
@apply leading-none flex items-center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
phoneNumber: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
formattedPhoneNumber() {
|
||||
return this.phoneNumber.replace(/\s|-|[A-Za-z]/g, '');
|
||||
},
|
||||
rawPhoneNumber() {
|
||||
return this.phoneNumber.replace(/\D/g, '');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async addContact() {
|
||||
try {
|
||||
let contact = await this.filterContactByNumber(this.rawPhoneNumber);
|
||||
if (!contact) {
|
||||
contact = await this.$store.dispatch(
|
||||
'contacts/create',
|
||||
this.getContactObject()
|
||||
);
|
||||
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
}
|
||||
this.openContactNewTab(contact.id);
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('phone_number')) {
|
||||
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
getContactObject() {
|
||||
const contactItem = {
|
||||
name: this.name,
|
||||
phone_number: `+${this.rawPhoneNumber}`,
|
||||
};
|
||||
return contactItem;
|
||||
},
|
||||
async filterContactByNumber(phoneNumber) {
|
||||
const query = {
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [phoneNumber],
|
||||
attribute_model: 'standard',
|
||||
custom_attribute_type: '',
|
||||
};
|
||||
|
||||
const queryPayload = { payload: [query] };
|
||||
const contacts = await this.$store.dispatch('contacts/filter', {
|
||||
queryPayload,
|
||||
resetState: false,
|
||||
});
|
||||
return contacts.shift();
|
||||
},
|
||||
openContactNewTab(contactId) {
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
const url = `/app/accounts/${accountId}/contacts/${contactId}`;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact--group">
|
||||
<fluent-icon icon="call" class="file--icon" size="18" />
|
||||
<div class="meta">
|
||||
<p
|
||||
class="overflow-hidden whitespace-nowrap text-ellipsis margin-bottom-0"
|
||||
>
|
||||
{{ phoneNumber }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="formattedPhoneNumber" class="link-wrap">
|
||||
<woot-button variant="clear" size="small" @click.prevent="addContact">
|
||||
{{ $t('CONVERSATION.SAVE_CONTACT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contact--group {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-top: var(--space-smaller);
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
.link-wrap {
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,86 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
fileName() {
|
||||
if (this.url) {
|
||||
const filename = this.url.substring(this.url.lastIndexOf('/') + 1);
|
||||
return filename || this.$t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
}
|
||||
return this.$t('CONVERSATION.UNKNOWN_FILE_TYPE');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openLink() {
|
||||
const win = window.open(this.url, '_blank', 'noopener');
|
||||
if (win) win.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file message-text__wrap">
|
||||
<div class="icon-wrap">
|
||||
<fluent-icon icon="document" class="file--icon" size="32" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h5 class="attachment-name text-slate-700 dark:text-slate-400">
|
||||
{{ decodeURI(fileName) }}
|
||||
</h5>
|
||||
<a
|
||||
class="download clear link button small"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="url"
|
||||
>
|
||||
{{ $t('CONVERSATION.DOWNLOAD') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: $space-smaller 0;
|
||||
cursor: pointer;
|
||||
|
||||
.icon-wrap {
|
||||
font-size: $font-size-giga;
|
||||
color: $color-white;
|
||||
line-height: 1;
|
||||
margin-left: $space-smaller;
|
||||
margin-right: $space-slab;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
margin: 0;
|
||||
color: $color-white;
|
||||
font-weight: $font-weight-bold;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: $color-primary-light;
|
||||
}
|
||||
|
||||
.meta {
|
||||
padding-right: $space-two;
|
||||
}
|
||||
|
||||
.time {
|
||||
min-width: $space-larger;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['error'],
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.show = false;
|
||||
},
|
||||
onClick() {
|
||||
this.show = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image message-text__wrap">
|
||||
<img :src="url" @click="onClick" @error="$emit('error')" />
|
||||
<woot-modal v-model:show="show" full-width :on-close="onClose">
|
||||
<img :src="url" class="modal-image skip-context-menu" />
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
AUDIO: 'audio',
|
||||
IG_REEL: 'ig_reel',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GalleryView,
|
||||
},
|
||||
props: {
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['error'],
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
isImageError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChatAttachments: 'getSelectedChatAttachments',
|
||||
}),
|
||||
isImage() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
|
||||
},
|
||||
isVideo() {
|
||||
return (
|
||||
this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
this.attachment.file_type === ALLOWED_FILE_TYPES.IG_REEL
|
||||
);
|
||||
},
|
||||
isAudio() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
|
||||
},
|
||||
timeStampURL() {
|
||||
return timeStampAppendedURL(this.dataUrl);
|
||||
},
|
||||
attachmentTypeClasses() {
|
||||
return {
|
||||
image: this.isImage,
|
||||
video: this.isVideo,
|
||||
};
|
||||
},
|
||||
filteredCurrentChatAttachments() {
|
||||
const attachments = this.currentChatAttachments.filter(attachment =>
|
||||
['image', 'video', 'audio'].includes(attachment.file_type)
|
||||
);
|
||||
return attachments;
|
||||
},
|
||||
dataUrl() {
|
||||
return this.attachment.data_url;
|
||||
},
|
||||
imageWidth() {
|
||||
return this.attachment.width ? `${this.attachment.width}px` : 'auto';
|
||||
},
|
||||
imageHeight() {
|
||||
return this.attachment.height ? `${this.attachment.height}px` : 'auto';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
attachment() {
|
||||
this.isImageError = false;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.show = false;
|
||||
},
|
||||
onClick(e) {
|
||||
if (hasPressedCommand(e)) {
|
||||
window.open(this.attachment.data_url, '_blank');
|
||||
return;
|
||||
}
|
||||
this.show = true;
|
||||
},
|
||||
onImgError() {
|
||||
this.isImageError = true;
|
||||
this.$emit('error');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-text__wrap" :class="attachmentTypeClasses">
|
||||
<img
|
||||
v-if="isImage && !isImageError"
|
||||
class="bg-woot-200 dark:bg-woot-900"
|
||||
:src="dataUrl"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
@click="onClick"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:src="dataUrl"
|
||||
muted
|
||||
playsInline
|
||||
@error="onImgError"
|
||||
@click="onClick"
|
||||
/>
|
||||
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<GalleryView
|
||||
v-if="show"
|
||||
v-model:show="show"
|
||||
:attachment="attachment"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="onImgError"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script>
|
||||
import BubbleImage from './Image.vue';
|
||||
import BubbleVideo from './Video.vue';
|
||||
import InstagramStoryErrorPlaceHolder from './InstagramStoryErrorPlaceHolder.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BubbleImage,
|
||||
BubbleVideo,
|
||||
InstagramStoryErrorPlaceHolder,
|
||||
},
|
||||
props: {
|
||||
storyUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['error'],
|
||||
data() {
|
||||
return {
|
||||
hasImgStoryError: false,
|
||||
hasVideoStoryError: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onImageLoadError() {
|
||||
this.hasImgStoryError = true;
|
||||
this.emitError();
|
||||
},
|
||||
onVideoLoadError() {
|
||||
this.hasVideoStoryError = true;
|
||||
this.emitError();
|
||||
},
|
||||
emitError() {
|
||||
this.$emit('error');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BubbleImage
|
||||
v-if="!hasImgStoryError"
|
||||
:url="storyUrl"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<BubbleVideo
|
||||
v-else-if="!hasVideoStoryError"
|
||||
:url="storyUrl"
|
||||
@error="onVideoLoadError"
|
||||
/>
|
||||
<InstagramStoryErrorPlaceHolder v-else />
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script>
|
||||
export default {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center px-8 h-28 w-full bg-slate-100 text-slate-700 dark:bg-slate-500 dark:text-slate-75"
|
||||
>
|
||||
<fluent-icon icon="document-error" size="32" />
|
||||
<p class="mb-0 text-slate-700 dark:text-slate-75">
|
||||
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import InstagramStory from './InstagramStory.vue';
|
||||
|
||||
export default {
|
||||
components: { InstagramStory },
|
||||
props: {
|
||||
storyUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImgStoryError: false,
|
||||
hasVideoStoryError: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onImageLoadError() {
|
||||
this.hasImgStoryError = true;
|
||||
},
|
||||
onVideoLoadError() {
|
||||
this.hasVideoStoryError = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<blockquote
|
||||
class="my-0 px-2 pb-0 pt-0 border-l-4 border-solid border-slate-75 dark:border-slate-600 text-slate-600 dark:text-slate-200"
|
||||
>
|
||||
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
|
||||
<InstagramStory :story-url="storyUrl" class="mt-3 rounded-md" />
|
||||
</blockquote>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import DyteVideoCall from './integrations/Dyte.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
export default {
|
||||
components: { DyteVideoCall },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
messageId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
contentAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showDyteIntegration() {
|
||||
const isEnabledOnTheInbox = this.isAPIInbox || this.isAWebWidgetInbox;
|
||||
return isEnabledOnTheInbox && this.contentAttributes.type === 'dyte';
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<DyteVideoCall
|
||||
v-if="showDyteIntegration"
|
||||
:message-id="messageId"
|
||||
:meeting-data="contentAttributes.data"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
latitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
longitude: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mapUrl = computed(
|
||||
() => `https://maps.google.com/?q=${props.latitude},${props.longitude}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row items-center justify-start gap-1 w-full py-1 px-0 cursor-pointer overflow-hidden"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="location"
|
||||
class="text-slate-600 dark:text-slate-200 leading-none my-0 flex items-center flex-shrink-0"
|
||||
size="32"
|
||||
/>
|
||||
<div class="flex flex-col items-start flex-1 min-w-0">
|
||||
<h5
|
||||
class="text-sm text-slate-800 dark:text-slate-100 truncate m-0 w-full"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
class="text-woot-600 dark:text-woot-600 text-xs underline"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:href="mapUrl"
|
||||
>
|
||||
{{ $t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
emailAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isIncoming: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
cc: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
bcc: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
fromMail() {
|
||||
const from = this.emailAttributes.from || [];
|
||||
return from.join(', ');
|
||||
},
|
||||
toMails() {
|
||||
const to = this.emailAttributes.to || [];
|
||||
return to.join(', ');
|
||||
},
|
||||
ccMails() {
|
||||
const cc = this.emailAttributes.cc || this.cc || [];
|
||||
return cc.join(', ');
|
||||
},
|
||||
bccMails() {
|
||||
const bcc = this.emailAttributes.bcc || this.bcc || [];
|
||||
return bcc.join(', ');
|
||||
},
|
||||
subject() {
|
||||
return this.emailAttributes.subject || '';
|
||||
},
|
||||
showHead() {
|
||||
return this.toMails || this.ccMails || this.bccMails || this.fromMail;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div
|
||||
v-if="showHead"
|
||||
class="message__mail-head"
|
||||
:class="{ 'is-incoming': isIncoming }"
|
||||
>
|
||||
<div v-if="fromMail" class="meta-wrap">
|
||||
<span class="message__content--type">{{ $t('EMAIL_HEADER.FROM') }}:</span>
|
||||
<span>{{ fromMail }}</span>
|
||||
</div>
|
||||
<div v-if="toMails" class="meta-wrap">
|
||||
<span class="message__content--type">{{ $t('EMAIL_HEADER.TO') }}:</span>
|
||||
<span>{{ toMails }}</span>
|
||||
</div>
|
||||
<div v-if="ccMails" class="meta-wrap">
|
||||
<span class="message__content--type">{{ $t('EMAIL_HEADER.CC') }}:</span>
|
||||
<span>{{ ccMails }}</span>
|
||||
</div>
|
||||
<div v-if="bccMails" class="meta-wrap">
|
||||
<span class="message__content--type">{{ $t('EMAIL_HEADER.BCC') }}:</span>
|
||||
<span>{{ bccMails }}</span>
|
||||
</div>
|
||||
<div v-if="subject" class="meta-wrap">
|
||||
<span class="message__content--type">
|
||||
{{ $t('EMAIL_HEADER.SUBJECT') }}:
|
||||
</span>
|
||||
<span>{{ subject }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message__mail-head {
|
||||
padding-bottom: var(--space-small);
|
||||
margin-bottom: var(--space-small);
|
||||
border-bottom: 1px solid var(--w-300);
|
||||
|
||||
&.is-incoming {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-wrap {
|
||||
.message__content--type {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
span {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script>
|
||||
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTo',
|
||||
components: {
|
||||
MessagePreview,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
messageType: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
parentHasAttachments: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { MESSAGE_TYPE };
|
||||
},
|
||||
methods: {
|
||||
scrollToMessage() {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId: this.message.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
|
||||
messageType === MESSAGE_TYPE.INCOMING,
|
||||
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
||||
'-mx-2': !parentHasAttachments,
|
||||
}"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<MessagePreview
|
||||
class="cursor-pointer"
|
||||
:message="message"
|
||||
:show-message-type="false"
|
||||
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,162 +0,0 @@
|
||||
<script>
|
||||
import { Letter } from 'vue-letter';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
|
||||
export default {
|
||||
components: { Letter, GalleryView },
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isEmail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
displayQuotedButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showQuotedContent: false,
|
||||
showGalleryViewer: false,
|
||||
attachment: {},
|
||||
availableAttachments: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isQuotedContentPresent() {
|
||||
if (!this.isEmail) {
|
||||
return this.message.includes('<blockquote');
|
||||
}
|
||||
return this.showQuotedContent;
|
||||
},
|
||||
showQuoteToggle() {
|
||||
if (!this.isEmail) {
|
||||
return false;
|
||||
}
|
||||
return this.displayQuotedButton;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleQuotedContent() {
|
||||
this.showQuotedContent = !this.showQuotedContent;
|
||||
},
|
||||
handleClickOnContent(event) {
|
||||
// if event target is IMG and not close in A tag
|
||||
// then open image preview
|
||||
const isImageElement = event.target.tagName === 'IMG';
|
||||
const isWrappedInLink = event.target.closest('A');
|
||||
|
||||
if (isImageElement && !isWrappedInLink) {
|
||||
this.openImagePreview(event.target.src);
|
||||
}
|
||||
},
|
||||
openImagePreview(src) {
|
||||
this.showGalleryViewer = true;
|
||||
this.attachment = {
|
||||
file_type: 'image',
|
||||
data_url: src,
|
||||
message_id: Math.floor(Math.random() * 100),
|
||||
};
|
||||
this.availableAttachments = [{ ...this.attachment }];
|
||||
},
|
||||
onClose() {
|
||||
this.showGalleryViewer = false;
|
||||
this.resetAttachmentData();
|
||||
},
|
||||
resetAttachmentData() {
|
||||
this.attachment = {};
|
||||
this.availableAttachments = [];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="message-text__wrap"
|
||||
:class="{
|
||||
'show--quoted': isQuotedContentPresent,
|
||||
'hide--quoted': !isQuotedContentPresent,
|
||||
}"
|
||||
>
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<div v-else @click="handleClickOnContent">
|
||||
<Letter
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="showQuoteToggle"
|
||||
class="py-1 text-xs cursor-pointer text-slate-300 dark:text-slate-300"
|
||||
@click="toggleQuotedContent"
|
||||
>
|
||||
<span v-if="showQuotedContent" class="flex items-center gap-0.5">
|
||||
<fluent-icon icon="chevron-up" size="16" />
|
||||
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-0.5">
|
||||
<fluent-icon icon="chevron-down" size="16" />
|
||||
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
|
||||
</span>
|
||||
</button>
|
||||
<GalleryView
|
||||
v-if="showGalleryViewer"
|
||||
v-model:show="showGalleryViewer"
|
||||
:attachment="attachment"
|
||||
:all-attachments="availableAttachments"
|
||||
@error="onClose"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.text-content {
|
||||
overflow: auto;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: var(--space-two);
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
|
||||
td {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.show--quoted {
|
||||
blockquote {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
.hide--quoted {
|
||||
blockquote {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
contentAttributes: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
computed: {
|
||||
translationsAvailable() {
|
||||
return !!Object.keys(this.translations).length;
|
||||
},
|
||||
translations() {
|
||||
return this.contentAttributes.translations || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal
|
||||
modal-type="right-aligned"
|
||||
class="text-left"
|
||||
show
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div class="content">
|
||||
<p>
|
||||
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
|
||||
</p>
|
||||
<p v-dompurify-html="content" class="mb-0" />
|
||||
<br />
|
||||
<hr />
|
||||
<div v-if="translationsAvailable">
|
||||
<p>
|
||||
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
|
||||
</p>
|
||||
<div v-for="(translation, language) in translations" :key="language">
|
||||
<p>
|
||||
<strong>{{ language }}:</strong>
|
||||
</p>
|
||||
<p v-dompurify-html="translation" />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['error'],
|
||||
data() {
|
||||
return {
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.videoElement.onerror = () => {
|
||||
this.$emit('error');
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.show = false;
|
||||
},
|
||||
onClick() {
|
||||
this.show = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="video message-text__wrap">
|
||||
<video ref="videoElement" :src="url" muted playsInline @click="onClick" />
|
||||
<woot-modal v-model:show="show" :on-close="onClose">
|
||||
<video
|
||||
:src="url"
|
||||
controls
|
||||
playsInline
|
||||
class="modal-video skip-context-menu mx-auto"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script>
|
||||
import DyteAPI from 'dashboard/api/integrations/dyte';
|
||||
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
messageId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
meetingData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { isLoading: false, dyteAuthToken: '', isSDKMounted: false };
|
||||
},
|
||||
computed: {
|
||||
meetingLink() {
|
||||
return buildDyteURL(this.meetingData.room_name, this.dyteAuthToken);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async joinTheCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { data: { authResponse: { authToken } = {} } = {} } =
|
||||
await DyteAPI.addParticipantToMeeting(this.messageId);
|
||||
this.dyteAuthToken = authToken;
|
||||
} catch (err) {
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
leaveTheRoom() {
|
||||
this.dyteAuthToken = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="video-add"
|
||||
class="join-call-button"
|
||||
:is-loading="isLoading"
|
||||
@click="joinTheCall"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN') }}
|
||||
</woot-button>
|
||||
<div v-if="dyteAuthToken" class="video-call--container">
|
||||
<iframe
|
||||
:src="meetingLink"
|
||||
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
|
||||
/>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
class="join-call-button"
|
||||
@click="leaveTheRoom"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.join-call-button {
|
||||
margin: var(--space-small) 0;
|
||||
}
|
||||
|
||||
.video-call--container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--z-index-high);
|
||||
padding: var(--space-smaller);
|
||||
background: var(--b-800);
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: var(--space-smaller);
|
||||
right: 10rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
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 Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachment: {
|
||||
@@ -26,7 +28,6 @@ const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
@@ -36,11 +37,7 @@ const ALLOWED_FILE_TYPES = {
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
const isDownloading = ref(false);
|
||||
const zoomScale = ref(1);
|
||||
const activeAttachment = ref({});
|
||||
const activeFileType = ref('');
|
||||
const activeImageIndex = ref(
|
||||
@@ -48,10 +45,23 @@ const activeImageIndex = ref(
|
||||
attachment => attachment.message_id === props.attachment.message_id
|
||||
) || 0
|
||||
);
|
||||
const activeImageRotation = ref(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
|
||||
);
|
||||
@@ -65,10 +75,10 @@ const readableTime = computed(() => {
|
||||
const isImage = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
||||
);
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
|
||||
const isVideo = computed(() =>
|
||||
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
|
||||
activeFileType.value
|
||||
)
|
||||
);
|
||||
const isAudio = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
||||
@@ -82,9 +92,9 @@ const senderDetails = computed(() => {
|
||||
thumbnail,
|
||||
id,
|
||||
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
||||
const currentUserID = currentUser.value?.id;
|
||||
|
||||
return {
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
});
|
||||
@@ -92,43 +102,32 @@ const senderDetails = computed(() => {
|
||||
const fileNameFromDataUrl = computed(() => {
|
||||
const { data_url: dataUrl } = activeAttachment.value;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return decodeURIComponent(fileName || '');
|
||||
|
||||
const fileName = dataUrl.split('/').pop();
|
||||
return fileName ? decodeURIComponent(fileName) : '';
|
||||
});
|
||||
|
||||
const imageRotationStyle = computed(() => ({
|
||||
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
|
||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
}));
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
const onClose = () => emit('close');
|
||||
|
||||
const setImageAndVideoSrc = attachment => {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
|
||||
activeAttachment.value = attachment;
|
||||
activeFileType.value = type;
|
||||
};
|
||||
|
||||
const onClickChangeAttachment = (attachment, index) => {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
if (!attachment) return;
|
||||
|
||||
activeImageIndex.value = index;
|
||||
setImageAndVideoSrc(attachment);
|
||||
activeImageRotation.value = 0;
|
||||
zoomScale.value = 1;
|
||||
resetZoomAndRotation();
|
||||
};
|
||||
|
||||
const onClickDownload = async () => {
|
||||
const { file_type: type, data_url: url, extension } = activeAttachment.value;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
|
||||
|
||||
try {
|
||||
isDownloading.value = true;
|
||||
@@ -140,66 +139,8 @@ const onClickDownload = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onRotate = type => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotation = type === 'clockwise' ? 90 : -90;
|
||||
|
||||
// Reset rotation if it is 360
|
||||
if (Math.abs(activeImageRotation.value) === 360) {
|
||||
activeImageRotation.value = rotation;
|
||||
} else {
|
||||
activeImageRotation.value += rotation;
|
||||
}
|
||||
};
|
||||
|
||||
const onZoom = scale => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newZoomScale = zoomScale.value + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
zoomScale.value = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// If within bounds, update the zoom scale
|
||||
zoomScale.value = newZoomScale;
|
||||
};
|
||||
|
||||
const onClickZoomImage = () => {
|
||||
// If already at max zoom, clicking should zoom out to minimum
|
||||
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
|
||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// Otherwise zoom in
|
||||
onZoom(0.1);
|
||||
};
|
||||
|
||||
const onWheelImageZoom = e => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
onZoom(scale);
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
Escape: { action: onClose },
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
onClickChangeAttachment(
|
||||
@@ -217,6 +158,7 @@ const keyboardEvents = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -225,172 +167,192 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal
|
||||
v-model:show="show"
|
||||
full-width
|
||||
:show-close-button="false"
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||
@click="onClose"
|
||||
<TeleportWithDirection to="body">
|
||||
<woot-modal
|
||||
v-model:show="show"
|
||||
full-width
|
||||
:show-close-button="false"
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-white dark:bg-slate-900"
|
||||
@click.stop
|
||||
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
|
||||
@click="onClose"
|
||||
>
|
||||
<div
|
||||
v-if="senderDetails"
|
||||
class="items-center flex justify-start min-w-[15rem]"
|
||||
<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
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="senderDetails.avatar"
|
||||
:username="senderDetails.name"
|
||||
:src="senderDetails.avatar"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
|
||||
v-if="senderDetails"
|
||||
class="flex items-center min-w-[15rem] shrink-0"
|
||||
>
|
||||
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
|
||||
<span
|
||||
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ senderDetails.name }}
|
||||
</span>
|
||||
</h3>
|
||||
<span
|
||||
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="fileNameFromDataUrl"
|
||||
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full">
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-left"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
:disabled="activeImageIndex === 0"
|
||||
@click.stop="
|
||||
onClickChangeAttachment(
|
||||
allAttachments[activeImageIndex - 1],
|
||||
activeImageIndex - 1
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center w-full h-full">
|
||||
<div>
|
||||
<img
|
||||
v-if="isImage"
|
||||
:key="activeAttachment.message_id"
|
||||
:src="activeAttachment.data_url"
|
||||
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
|
||||
:style="imageRotationStyle"
|
||||
@click.stop="onClickZoomImage"
|
||||
@wheel.stop="onWheelImageZoom"
|
||||
<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="mx-auto my-0 modal-video skip-context-menu"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<audio
|
||||
v-if="isAudio"
|
||||
:key="activeAttachment.message_id"
|
||||
controls
|
||||
class="skip-context-menu"
|
||||
class="w-full max-w-md"
|
||||
@click.stop
|
||||
>
|
||||
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
|
||||
<NextButton
|
||||
v-if="hasMoreThanOneAttachment"
|
||||
icon="i-lucide-chevron-right"
|
||||
class="z-10 disabled:pointer-events-auto"
|
||||
blue
|
||||
faded
|
||||
lg
|
||||
:disabled="activeImageIndex === allAttachments.length - 1"
|
||||
@click.stop="
|
||||
onClickChangeAttachment(
|
||||
allAttachments[activeImageIndex + 1],
|
||||
activeImageIndex + 1
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="z-10 flex items-center justify-center w-full h-16 px-6 py-2">
|
||||
<div
|
||||
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
|
||||
|
||||
<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"
|
||||
>
|
||||
<span class="count">
|
||||
<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}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</woot-modal>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
|
||||
@@ -1,132 +1,115 @@
|
||||
<script>
|
||||
<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();
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SLAPopoverCard,
|
||||
},
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showExtendedInfo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timer: null,
|
||||
showSlaPopover: false,
|
||||
slaStatus: {
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
slaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
appliedSLA() {
|
||||
return this.chat?.applied_sla;
|
||||
},
|
||||
slaEvents() {
|
||||
return this.chat?.sla_events;
|
||||
},
|
||||
hasSlaThreshold() {
|
||||
return this.slaStatus?.threshold;
|
||||
},
|
||||
isSlaMissed() {
|
||||
return this.slaStatus?.isSlaMissed;
|
||||
},
|
||||
slaTextStyles() {
|
||||
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
|
||||
},
|
||||
slaStatusText() {
|
||||
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||
});
|
||||
},
|
||||
showSlaPopoverCard() {
|
||||
return (
|
||||
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
chat() {
|
||||
this.updateSlaStatus();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
},
|
||||
unmounted() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createTimer() {
|
||||
this.timer = setTimeout(() => {
|
||||
this.updateSlaStatus();
|
||||
this.createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
},
|
||||
updateSlaStatus() {
|
||||
this.slaStatus = evaluateSLAStatus({
|
||||
appliedSla: this.appliedSLA,
|
||||
chat: this.chat,
|
||||
});
|
||||
},
|
||||
openSlaPopover() {
|
||||
if (!this.showExtendedInfo) return;
|
||||
this.showSlaPopover = true;
|
||||
},
|
||||
closeSlaPopover() {
|
||||
this.showSlaPopover = false;
|
||||
},
|
||||
},
|
||||
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"
|
||||
:class="
|
||||
showExtendedInfo
|
||||
? 'h-[26px] rounded-lg bg-n-alpha-1'
|
||||
: 'rounded h-5 border border-n-strong'
|
||||
"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
:class="groupClass"
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="closeSlaPopover"
|
||||
class="flex items-center w-full truncate"
|
||||
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
|
||||
@mouseover="openSlaPopover()"
|
||||
class="flex items-center w-full truncate px-1.5"
|
||||
:class="showExtendedInfo ? '' : 'gap-1'"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="
|
||||
showExtendedInfo &&
|
||||
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-1" :class="slaPopoverClass">
|
||||
<fluent-icon
|
||||
size="14"
|
||||
size="12"
|
||||
:icon="slaStatus.icon"
|
||||
type="outline"
|
||||
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||
@@ -134,7 +117,7 @@ export default {
|
||||
:class="slaTextStyles"
|
||||
/>
|
||||
<span
|
||||
v-if="showExtendedInfo"
|
||||
v-if="showExtendedInfo && parentWidth > 650"
|
||||
class="text-xs font-medium"
|
||||
:class="slaTextStyles"
|
||||
>
|
||||
@@ -151,7 +134,7 @@ export default {
|
||||
<SLAPopoverCard
|
||||
v-if="showSlaPopoverCard"
|
||||
:sla-missed-events="slaEvents"
|
||||
class="right-0 top-7"
|
||||
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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: {
|
||||
@@ -60,20 +61,19 @@ const toggleShowAllNRT = () => {
|
||||
v-if="shouldShowMoreNRTButton"
|
||||
class="flex flex-col items-end w-full"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="!shouldShowAllNrts ? 'plus-sign' : ''"
|
||||
variant="link"
|
||||
color-scheme="secondary"
|
||||
class="hover:!no-underline !gap-1 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
@click="toggleShowAllNRT"
|
||||
>
|
||||
{{
|
||||
<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 })
|
||||
}}
|
||||
</woot-button>
|
||||
"
|
||||
@click="toggleShowAllNRT"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SLAEventItem>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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,
|
||||
@@ -9,6 +12,20 @@ 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,
|
||||
@@ -36,21 +53,43 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conversationUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowedOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'updateConversation',
|
||||
'assignPriority',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabel',
|
||||
'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',
|
||||
icon: 'mail-unread',
|
||||
},
|
||||
statusMenuConfig: [
|
||||
{
|
||||
@@ -58,16 +97,16 @@ export default {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
|
||||
icon: 'checkmark',
|
||||
},
|
||||
{
|
||||
key: wootConstants.STATUS_TYPE.PENDING,
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
|
||||
icon: 'book-clock',
|
||||
},
|
||||
{
|
||||
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,
|
||||
@@ -75,7 +114,7 @@ export default {
|
||||
icon: 'snooze',
|
||||
},
|
||||
priorityConfig: {
|
||||
key: 'priority',
|
||||
key: MENU.PRIORITY,
|
||||
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
||||
icon: 'warning',
|
||||
options: [
|
||||
@@ -102,20 +141,35 @@ export default {
|
||||
].filter(item => item.key !== this.priority),
|
||||
},
|
||||
labelMenuConfig: {
|
||||
key: 'label',
|
||||
key: MENU.LABEL,
|
||||
icon: 'tag',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
||||
},
|
||||
agentMenuConfig: {
|
||||
key: 'agent',
|
||||
key: MENU.AGENT,
|
||||
icon: 'person-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
||||
},
|
||||
teamMenuConfig: {
|
||||
key: 'team',
|
||||
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: {
|
||||
@@ -162,6 +216,10 @@ export default {
|
||||
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);
|
||||
},
|
||||
@@ -173,6 +231,27 @@ export default {
|
||||
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.
|
||||
@@ -196,74 +275,120 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
|
||||
<MenuItem
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click.stop="$emit('markAsUnread')"
|
||||
/>
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<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="show(option.key)"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
@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>
|
||||
<MenuItem
|
||||
v-if="showSnooze"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click.stop="snoozeConversation()"
|
||||
/>
|
||||
|
||||
<MenuItemWithSubmenu :option="priorityConfig">
|
||||
<MenuItem
|
||||
v-for="(option, i) in priorityConfig.options"
|
||||
:key="i"
|
||||
:option="option"
|
||||
@click.stop="assignPriority(option.key)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click.stop="$emit('assignLabel', label)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
:option="agentMenuConfig"
|
||||
:sub-menu-available="!!assignableAgents.length"
|
||||
>
|
||||
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||
<template v-else>
|
||||
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<MenuItem
|
||||
v-for="agent in assignableAgents"
|
||||
:key="agent.id"
|
||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||
variant="agent"
|
||||
@click.stop="$emit('assignAgent', agent)"
|
||||
v-if="show(option.key) && isAllowed([MENU.STATUS])"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
/>
|
||||
</template>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
: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)"
|
||||
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click.stop="snoozeConversation()"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<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="label"
|
||||
@click.stop="$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>
|
||||
|
||||
@@ -21,11 +21,11 @@ export default {
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--space-normal) 0;
|
||||
min-width: calc(var(--space-mega) * 2);
|
||||
padding: 1rem 0;
|
||||
min-width: calc(6.25rem * 2);
|
||||
|
||||
p {
|
||||
margin: var(--space-small) 0 0 0;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
props: {
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="menu text-slate-800 dark:text-slate-100 min-h-7 min-w-0"
|
||||
role="button"
|
||||
>
|
||||
<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"
|
||||
@@ -33,12 +26,12 @@ export default {
|
||||
class="label-pill flex-shrink-0"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:name="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
:status="option.status === 'online' ? option.status : null"
|
||||
:size="20"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<p class="menu-label truncate min-w-0 flex-1">
|
||||
@@ -49,7 +42,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu {
|
||||
width: calc(var(--space-mega) * 2);
|
||||
width: calc(6.25rem * 2);
|
||||
@apply flex items-center flex-nowrap p-1 rounded-md overflow-hidden cursor-pointer;
|
||||
|
||||
.menu-label {
|
||||
@@ -57,7 +50,7 @@ export default {
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-brand text-white dark:text-slate-50;
|
||||
@apply bg-n-brand text-white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const submenuPosition = computed(() => [
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="text-slate-800 dark:text-slate-100 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="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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
// components
|
||||
import WootButton from '../../../ui/WootButton.vue';
|
||||
import Avatar from '../../Avatar.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
// composables
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
@@ -19,7 +19,7 @@ export default {
|
||||
name: 'LabelSuggestion',
|
||||
components: {
|
||||
Avatar,
|
||||
WootButton,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
suggestedLabels: {
|
||||
@@ -154,7 +154,7 @@ export default {
|
||||
<template>
|
||||
<li
|
||||
v-if="shouldShowSuggestions"
|
||||
class="label-suggestion right"
|
||||
class="label-suggestion right list-none"
|
||||
@mouseover="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
@@ -180,64 +180,62 @@ export default {
|
||||
<woot-label
|
||||
variant="dashed"
|
||||
v-bind="label"
|
||||
:bg-color="
|
||||
selectedLabels.includes(label.title) ? 'var(--w-100)' : ''
|
||||
"
|
||||
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
|
||||
/>
|
||||
</button>
|
||||
<WootButton
|
||||
<NextButton
|
||||
v-if="preparedLabels.length === 1"
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
variant="smooth"
|
||||
:color-scheme="isHovered ? 'alert' : 'primary'"
|
||||
class="label--add"
|
||||
icon="dismiss"
|
||||
size="tiny"
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
:color="isHovered ? 'ruby' : 'blue'"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="preparedLabels.length > 1">
|
||||
<WootButton
|
||||
:variant="selectedLabels.length === 0 ? 'smooth' : ''"
|
||||
class="label--add"
|
||||
icon="add"
|
||||
size="tiny"
|
||||
<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"
|
||||
>
|
||||
{{ addButtonText }}
|
||||
</WootButton>
|
||||
<WootButton
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
:color-scheme="isHovered ? 'alert' : 'primary'"
|
||||
variant="smooth"
|
||||
class="label--add"
|
||||
icon="dismiss"
|
||||
size="tiny"
|
||||
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">
|
||||
<woot-thumbnail
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
size="16px"
|
||||
>
|
||||
<Avatar class="user-thumbnail thumbnail-rounded">
|
||||
<fluent-icon class="chatwoot-ai-icon" icon="chatwoot-ai" />
|
||||
</Avatar>
|
||||
</woot-thumbnail>
|
||||
:size="16"
|
||||
name="chatwoot-ai"
|
||||
icon-name="i-lucide-sparkles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -251,17 +249,14 @@ export default {
|
||||
.label-suggestion {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-normal);
|
||||
margin-top: 1rem;
|
||||
|
||||
.label-suggestion--container {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.label-suggestion--options {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-micro);
|
||||
@apply gap-0.5 text-end flex items-center;
|
||||
|
||||
button.label-suggestion--option {
|
||||
.label {
|
||||
@@ -271,15 +266,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.chatwoot-ai-icon {
|
||||
height: var(--font-size-mini);
|
||||
width: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.label-suggestion--title {
|
||||
color: var(--b-600);
|
||||
margin-top: var(--space-micro);
|
||||
font-size: var(--font-size-micro);
|
||||
@apply text-n-slate-11 mt-0.5 text-xxs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
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: {
|
||||
Thumbnail,
|
||||
Avatar,
|
||||
Spinner,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
selectedInboxes: {
|
||||
@@ -94,13 +96,7 @@ export default {
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div
|
||||
@@ -121,19 +117,21 @@ export default {
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="agent--search_input"
|
||||
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)">
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
:name="agent.name"
|
||||
:src="agent.thumbnail"
|
||||
:status="agent.availability_status"
|
||||
:username="agent.name"
|
||||
size="22px"
|
||||
:size="22"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<span class="my-0 text-slate-800 dark:text-slate-75">
|
||||
<span class="my-0 text-n-slate-12">
|
||||
{{ agent.name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -161,21 +159,21 @@ export default {
|
||||
}}
|
||||
</p>
|
||||
<div class="agent-confirmation-actions">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="smooth"
|
||||
<NextButton
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ $t('BULK_ACTION.GO_BACK_LABEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
variant="flat"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.YES')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('BULK_ACTION.YES') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,7 +183,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__agents {
|
||||
@apply max-w-[75%] absolute right-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;
|
||||
@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;
|
||||
|
||||
@@ -202,7 +200,7 @@ export default {
|
||||
.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-slate-400 dark:text-slate-200;
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@@ -211,8 +209,7 @@ export default {
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
right: var(--triangle-position);
|
||||
@apply block z-10 absolute -top-3 text-left;
|
||||
@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;
|
||||
@@ -252,6 +249,6 @@ ul {
|
||||
}
|
||||
|
||||
.agent__list-loading {
|
||||
@apply m-2.5 rounded-md bg-slate-25 dark:bg-slate-900 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
|
||||
@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>
|
||||
|
||||
@@ -262,17 +262,8 @@ export default {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// For RTL direction view
|
||||
.app-rtl--wrapper {
|
||||
.bulk-action__actions {
|
||||
::v-deep .button--only-icon:last-child {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-action__container {
|
||||
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
|
||||
}
|
||||
|
||||
.bulk-action__panel {
|
||||
@@ -288,7 +279,7 @@ export default {
|
||||
}
|
||||
|
||||
.bulk-action__alert {
|
||||
@apply bg-yellow-50 text-yellow-700 rounded text-xs mt-2 py-1 px-2 border border-solid border-yellow-300 dark:border-yellow-300/10 dark:bg-yellow-200/20 dark:text-yellow-400;
|
||||
@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,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['update', 'close', 'assign'],
|
||||
data() {
|
||||
return {
|
||||
@@ -40,13 +44,7 @@ export default {
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="labels-list">
|
||||
<header class="labels-list__header">
|
||||
@@ -58,7 +56,7 @@ export default {
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="label--search_input"
|
||||
class="reset-base !outline-0 !text-sm label--search_input"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
@@ -91,15 +89,13 @@ export default {
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="labels-list__footer">
|
||||
<woot-button
|
||||
size="small"
|
||||
is-expanded
|
||||
color-scheme="primary"
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="$emit('assign', selectedLabels)"
|
||||
>
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS') }}</span>
|
||||
</woot-button>
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +130,7 @@ export default {
|
||||
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
|
||||
|
||||
.search-icon {
|
||||
@apply text-slate-400 dark:text-slate-200;
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.label--search_input {
|
||||
@@ -143,7 +139,7 @@ export default {
|
||||
}
|
||||
|
||||
.labels-container {
|
||||
@apply absolute right-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] 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;
|
||||
@@ -162,8 +158,7 @@ export default {
|
||||
}
|
||||
|
||||
.triangle {
|
||||
right: var(--triangle-position);
|
||||
@apply block z-10 absolute text-left -top-3;
|
||||
@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;
|
||||
@@ -186,7 +181,7 @@ ul {
|
||||
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
|
||||
&.label-selected {
|
||||
@apply bg-slate-50 dark:bg-slate-900;
|
||||
@apply bg-n-slate-2;
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -194,7 +189,7 @@ ul {
|
||||
}
|
||||
|
||||
.label-checkbox {
|
||||
@apply my-0 mr-2.5 ml-0;
|
||||
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
@@ -202,7 +197,7 @@ ul {
|
||||
}
|
||||
|
||||
.label-pill {
|
||||
@apply bg-slate-50 rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
|
||||
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,12 +205,4 @@ ul {
|
||||
.search-container {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
@apply bg-white dark:bg-slate-900 bottom-0 p-2 sticky z-20;
|
||||
|
||||
button {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['assignTeam', 'close'],
|
||||
|
||||
data() {
|
||||
@@ -40,13 +45,7 @@ export default {
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="team__list-container">
|
||||
@@ -60,16 +59,14 @@ export default {
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="agent--search_input"
|
||||
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-slate-800 dark:text-slate-75"
|
||||
>
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -77,9 +74,7 @@ export default {
|
||||
</template>
|
||||
<li v-else>
|
||||
<div class="team__list-item">
|
||||
<span
|
||||
class="my-0 ltr:ml-2 rtl:mr-2 text-slate-800 dark:text-slate-75"
|
||||
>
|
||||
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -92,7 +87,7 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bulk-action__teams {
|
||||
@apply max-w-[75%] absolute right-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;
|
||||
@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;
|
||||
|
||||
@@ -109,7 +104,7 @@ export default {
|
||||
.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-slate-400 dark:text-slate-200;
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.agent--search_input {
|
||||
@@ -118,8 +113,7 @@ export default {
|
||||
}
|
||||
}
|
||||
.triangle {
|
||||
right: var(--triangle-position);
|
||||
@apply block z-10 absolute text-left -top-3;
|
||||
@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;
|
||||
|
||||
@@ -4,6 +4,7 @@ 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: {
|
||||
@@ -25,9 +26,9 @@ const emit = defineEmits(['update', 'close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const actions = ref([
|
||||
{ icon: 'checkmark', key: 'resolved' },
|
||||
{ icon: 'arrow-redo', key: 'open' },
|
||||
{ icon: 'send-clock', key: 'snoozed' },
|
||||
{ icon: 'i-lucide-check', key: 'resolved' },
|
||||
{ icon: 'i-lucide-redo', key: 'open' },
|
||||
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
|
||||
]);
|
||||
|
||||
const updateConversations = key => {
|
||||
@@ -67,7 +68,7 @@ const actionLabel = key => {
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md right-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
|
||||
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"
|
||||
@@ -82,31 +83,24 @@ const actionLabel = key => {
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-2.5 flex gap-1 items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-100">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
|
||||
</span>
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
<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">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
<Button
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
class="!w-full !justify-start"
|
||||
:icon="action.icon"
|
||||
class="hover:!bg-n-slate-3 dark:hover:!bg-n-solid-3"
|
||||
:label="actionLabel(action.key)"
|
||||
@click="updateConversations(action.key)"
|
||||
>
|
||||
{{ actionLabel(action.key) }}
|
||||
</woot-button>
|
||||
/>
|
||||
</WootDropdownItem>
|
||||
</template>
|
||||
</WootDropdownMenu>
|
||||
|
||||
@@ -86,7 +86,7 @@ const onShowLabels = e => {
|
||||
? $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-slate-700 dark:text-slate-200 border-n-strong dark:border-n-strong"
|
||||
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
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { CSAT_RATINGS } from '../../../../../shared/constants/messages';
|
||||
|
||||
const generateInputSelectContent = contentAttributes => {
|
||||
const { submitted_values: submittedValues = [] } = contentAttributes;
|
||||
const [selectedOption] = submittedValues;
|
||||
|
||||
if (selectedOption && selectedOption.title) {
|
||||
return `<strong>${selectedOption.title}</strong>`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const generateInputEmailContent = contentAttributes => {
|
||||
const { submitted_email: submittedEmail = '' } = contentAttributes;
|
||||
if (submittedEmail) {
|
||||
return `<strong>${submittedEmail}</strong>`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const generateFormContent = (contentAttributes, { noResponseText }) => {
|
||||
const { items, submitted_values: submittedValues = [] } = contentAttributes;
|
||||
if (submittedValues.length) {
|
||||
const submittedObject = submittedValues.reduce((acc, keyValuePair) => {
|
||||
acc[keyValuePair.name] = keyValuePair.value;
|
||||
return acc;
|
||||
}, {});
|
||||
let formMessageContent = '';
|
||||
items.forEach(item => {
|
||||
formMessageContent += `<div>${item.label}</div>`;
|
||||
const response = submittedObject[item.name] || noResponseText;
|
||||
formMessageContent += `<strong>${response}</strong><br/><br/>`;
|
||||
});
|
||||
return formMessageContent;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const generateCSATContent = (
|
||||
contentAttributes,
|
||||
{ ratingTitle, feedbackTitle }
|
||||
) => {
|
||||
const {
|
||||
submitted_values: { csat_survey_response: surveyResponse = {} } = {},
|
||||
} = contentAttributes;
|
||||
const { rating, feedback_message } = surveyResponse || {};
|
||||
|
||||
let messageContent = '';
|
||||
if (rating) {
|
||||
const [ratingObject = {}] = CSAT_RATINGS.filter(
|
||||
csatRating => csatRating.value === rating
|
||||
);
|
||||
messageContent += `<div><strong>${ratingTitle}</strong></div>`;
|
||||
messageContent += `<p>${ratingObject.emoji}</p>`;
|
||||
}
|
||||
if (feedback_message) {
|
||||
messageContent += `<div><strong>${feedbackTitle}</strong></div>`;
|
||||
messageContent += `<p>${feedback_message}</p>`;
|
||||
}
|
||||
return messageContent;
|
||||
};
|
||||
|
||||
export const generateBotMessageContent = (
|
||||
contentType,
|
||||
contentAttributes,
|
||||
{
|
||||
noResponseText = 'No response',
|
||||
csat: { ratingTitle = 'Rating', feedbackTitle = 'Feedback' } = {},
|
||||
} = {}
|
||||
) => {
|
||||
const contentTypeMethods = {
|
||||
input_select: generateInputSelectContent,
|
||||
input_email: generateInputEmailContent,
|
||||
form: generateFormContent,
|
||||
input_csat: generateCSATContent,
|
||||
};
|
||||
|
||||
const contentTypeMethod = contentTypeMethods[contentType];
|
||||
if (contentTypeMethod && typeof contentTypeMethod === 'function') {
|
||||
return contentTypeMethod(contentAttributes, {
|
||||
noResponseText,
|
||||
ratingTitle,
|
||||
feedbackTitle,
|
||||
});
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { generateBotMessageContent } from '../botMessageContentHelper';
|
||||
|
||||
describe('#generateBotMessageContent', () => {
|
||||
it('return correct input_select content', () => {
|
||||
expect(
|
||||
generateBotMessageContent('input_select', {
|
||||
submitted_values: [{ value: 'salad', title: 'Salad' }],
|
||||
})
|
||||
).toEqual('<strong>Salad</strong>');
|
||||
});
|
||||
|
||||
it('return correct input_email content', () => {
|
||||
expect(
|
||||
generateBotMessageContent('input_email', {
|
||||
submitted_email: 'hello@chatwoot.com',
|
||||
})
|
||||
).toEqual('<strong>hello@chatwoot.com</strong>');
|
||||
});
|
||||
|
||||
it('return correct input_csat content', () => {
|
||||
expect(
|
||||
generateBotMessageContent('input_csat', {
|
||||
submitted_values: {
|
||||
csat_survey_response: {
|
||||
rating: 5,
|
||||
feedback_message: 'Great Service',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual(
|
||||
'<div><strong>Rating</strong></div><p>😍</p><div><strong>Feedback</strong></div><p>Great Service</p>'
|
||||
);
|
||||
|
||||
expect(
|
||||
generateBotMessageContent(
|
||||
'input_csat',
|
||||
{
|
||||
submitted_values: {
|
||||
csat_survey_response: { rating: 1, feedback_message: '' },
|
||||
},
|
||||
},
|
||||
{ csat: { ratingTitle: 'റേറ്റിംഗ്', feedbackTitle: 'പ്രതികരണം' } }
|
||||
)
|
||||
).toEqual('<div><strong>റേറ്റിംഗ്</strong></div><p>😞</p>');
|
||||
});
|
||||
|
||||
it('return correct form content', () => {
|
||||
expect(
|
||||
generateBotMessageContent('form', {
|
||||
items: [
|
||||
{
|
||||
name: 'large_text',
|
||||
label: 'This a large text',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
},
|
||||
],
|
||||
submitted_values: [{ name: 'large_text', value: 'Large Text Content' }],
|
||||
})
|
||||
).toEqual(
|
||||
'<div>This a large text</div><strong>Large Text Content</strong><br/><br/><div>Email</div><strong>No response</strong><br/><br/>'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,14 @@
|
||||
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: {
|
||||
@@ -45,7 +48,7 @@ const statusDesiredOrder = [
|
||||
];
|
||||
|
||||
const isCreating = ref(false);
|
||||
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
|
||||
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
@@ -180,14 +183,20 @@ const createIssue = async () => {
|
||||
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 { id: issueId } = response.data;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||
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(
|
||||
@@ -209,7 +218,7 @@ onMounted(getTeams);
|
||||
v-model="formState.title"
|
||||
:class="{ error: v$.title.$error }"
|
||||
class="w-full"
|
||||
:styles="{ ...inputStyles, padding: '6px 12px' }"
|
||||
: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')
|
||||
@@ -221,7 +230,7 @@ onMounted(getTeams);
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="formState.description"
|
||||
:style="{ ...inputStyles, padding: '8px 12px' }"
|
||||
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
|
||||
rows="3"
|
||||
class="text-sm"
|
||||
:placeholder="
|
||||
@@ -245,20 +254,20 @@ onMounted(getTeams);
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-full gap-2 mt-8">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE')"
|
||||
:disabled="isSubmitDisabled"
|
||||
:is-loading="isCreating"
|
||||
@click.prevent="createIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -58,7 +58,7 @@ const onClickTabChange = index => {
|
||||
/>
|
||||
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-col px-8 pb-4">
|
||||
<div class="flex flex-col px-8 pb-4 mt-1">
|
||||
<woot-tabs
|
||||
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
|
||||
:index="selectedTabIndex"
|
||||
@@ -70,6 +70,7 @@ const onClickTabChange = index => {
|
||||
:index="index"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
/>
|
||||
</woot-tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
linkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const { createdAt } = props.issue;
|
||||
return format(new Date(createdAt), 'hh:mm a, MMM dd');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = props.issue.assignee;
|
||||
|
||||
if (!assigneeDetails) return null;
|
||||
const { name, avatarUrl } = assigneeDetails;
|
||||
|
||||
return {
|
||||
name,
|
||||
thumbnail: avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
return props.issue.labels?.nodes || [];
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
return priorityMap[props.issue.priority];
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkId"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-ash-900">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-ash-800 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-ash-800">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
identifier: {
|
||||
type: String,
|
||||
@@ -13,8 +14,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const isUnlinking = inject('isUnlinking');
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue');
|
||||
};
|
||||
@@ -25,42 +24,33 @@ const openIssue = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
|
||||
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class="h-[24px]"
|
||||
:is-loading="isUnlinking"
|
||||
@click="unlinkIssue"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<fluent-icon
|
||||
v-if="!isUnlinking"
|
||||
icon="unlink"
|
||||
size="12"
|
||||
type="outline"
|
||||
icon-lib="lucide"
|
||||
icon="linear"
|
||||
size="16"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
class="h-[24px]"
|
||||
color-scheme="secondary"
|
||||
<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"
|
||||
>
|
||||
<fluent-icon icon="arrow-up-right" size="14" />
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import LinearIssueItem from './LinearIssueItem.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const linkedIssues = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const shouldShowCreateModal = ref(false);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(
|
||||
() => getters.getConversationById.value(props.conversationId) || {}
|
||||
);
|
||||
|
||||
const hasIssues = computed(() => linkedIssues.value.length > 0);
|
||||
|
||||
const loadLinkedIssues = async () => {
|
||||
isLoading.value = true;
|
||||
linkedIssues.value = [];
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
linkedIssues.value = response.data || [];
|
||||
} catch (error) {
|
||||
// Silent fail - not critical for UX
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async (linkId, issueIdentifier) => {
|
||||
try {
|
||||
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssues.value = linkedIssues.value.filter(
|
||||
issue => issue.id !== linkId
|
||||
);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
shouldShowCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
shouldShowCreateModal.value = false;
|
||||
loadLinkedIssues();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssues();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssues();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasIssues" class="flex justify-center p-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[300px] overflow-y-auto">
|
||||
<LinearIssueItem
|
||||
v-for="linkedIssue in linkedIssues"
|
||||
:key="linkedIssue.id"
|
||||
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
|
||||
:linked-issue="linkedIssue"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="shouldShowCreateModal"
|
||||
:on-close="closeCreateModal"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<CreateOrLinkIssue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closeCreateModal"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
linkedIssue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const { linkedIssue } = props;
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const issue = computed(() => linkedIssue.issue);
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = issue.value.assignee;
|
||||
if (!assigneeDetails) return null;
|
||||
return {
|
||||
name: assigneeDetails.name,
|
||||
thumbnail: assigneeDetails.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => issue.value.labels?.nodes || []);
|
||||
|
||||
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkedIssue.id"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="assignee" class="flex items-center gap-1.5">
|
||||
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
|
||||
<span class="text-xs capitalize truncate text-n-slate-12">
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-activity"
|
||||
class="size-4"
|
||||
:style="{ color: issue.state?.color }"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ issue.state?.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="labels.length" class="flex flex-wrap">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
|
||||
const integrationId = 'linear';
|
||||
|
||||
const actionURL = computed(() =>
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/integrations/${integrationId}`
|
||||
)
|
||||
);
|
||||
|
||||
const openLinearAccount = () => {
|
||||
window.open(actionURL.value, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="w-12 h-12 mb-3">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}.png`"
|
||||
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
|
||||
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mb-4">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
|
||||
</h3>
|
||||
<p v-if="isAdmin" class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -60,7 +63,7 @@ const onSearch = async value => {
|
||||
isFetching.value = true;
|
||||
const response = await LinearAPI.searchIssues(value);
|
||||
issues.value = response.data.map(issue => ({
|
||||
id: issue.id,
|
||||
id: issue.identifier,
|
||||
name: `${issue.identifier} ${issue.title}`,
|
||||
icon: 'status',
|
||||
iconColor: issue.state.color,
|
||||
@@ -85,6 +88,7 @@ const linkIssue = async () => {
|
||||
searchQuery.value = '';
|
||||
issues.value = [];
|
||||
onClose();
|
||||
useTrack(LINEAR_EVENTS.LINK_ISSUE);
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
@@ -103,9 +107,10 @@ const linkIssue = async () => {
|
||||
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
|
||||
>
|
||||
<FilterButton
|
||||
right-icon="chevron-down"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:button-text="linkIssueTitle"
|
||||
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
|
||||
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="shouldShowDropdown" #dropdown>
|
||||
@@ -126,20 +131,20 @@ const linkIssue = async () => {
|
||||
</template>
|
||||
</FilterButton>
|
||||
<div class="flex items-center justify-end w-full gap-2 mt-2">
|
||||
<woot-button
|
||||
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
|
||||
@click.prevent="onClose"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
:is-disabled="isSubmitDisabled"
|
||||
class="px-4 rounded-xl"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE')"
|
||||
:disabled="isSubmitDisabled"
|
||||
:is-loading="isLinking"
|
||||
@click.prevent="linkIssue"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,9 +50,10 @@ const selectedItemId = computed(() => selectedItem.value?.id || null);
|
||||
<label class="w-full" :class="{ error: hasError }">
|
||||
{{ label }}
|
||||
<FilterButton
|
||||
right-icon="chevron-down"
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:button-text="selectedItemName"
|
||||
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
|
||||
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template v-if="shouldShowDropdown" #dropdown>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user