chore: Replace Thumbnail with Avatar (#12119)

This commit is contained in:
Sivin Varghese
2025-08-11 15:47:17 +05:30
committed by GitHub
parent fcc6e2b8b2
commit d908c880d2
38 changed files with 297 additions and 657 deletions

View File

@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
});
</script>

View File

@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const lastActivityAt = computed(() => {

View File

@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const hasSlaThreshold = computed(() => {

View File

@@ -36,10 +36,11 @@ const transformInbox = ({
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,

View File

@@ -183,7 +183,10 @@ watch(
</script>
<template>
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<!-- Status Badge -->
<slot name="badge" :size="size">
<div

View File

@@ -57,9 +57,10 @@ const menuItems = computed(() => [
},
]);
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
});
const handleAction = ({ action, value }) => {
toggleDropdown(false);

View File

@@ -1,6 +1,5 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -18,11 +17,9 @@ import Modal from './Modal.vue';
import Spinner from 'shared/components/Spinner.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -40,7 +37,6 @@ const WootUIKit = {
Spinner,
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);

View File

@@ -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-n-blue-text;
}
</style>

View File

@@ -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');
});
});

View File

@@ -1,224 +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-n-weak'
: '';
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 z-20"
:style="badgeStyle"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
class="z-20"
: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: 0.5625rem;
}
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: 0.1875rem;
bottom: -0.125rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
position: absolute;
right: 0;
@apply bg-n-background p-0.5 size-3;
}
.user-online-status {
@apply bottom-0.5 rounded-full;
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-n-teal-10;
}
.user-online-status--busy {
@apply bg-n-amber-10;
}
.user-online-status--offline {
@apply bg-n-slate-10;
}
}
</style>

View File

@@ -1,78 +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: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:not(:first-child) {
margin-left: -0.25rem;
}
.gap-tight {
margin-left: -0.5rem;
}
}
.thumbnail-more-text {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
@apply text-n-slate-11 bg-n-slate-4 border border-n-weak text-xs font-medium rounded-full px-2 ltr:-ml-2 rtl:-mr-2 inline-flex items-center relative;
}
</style>

View File

@@ -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,11 +19,13 @@ 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 truncate text-capitalize" :class="textClass">
{{ user.name }}

View File

@@ -6,7 +6,7 @@ import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
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';
@@ -105,12 +105,13 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
<Avatar
:name="currentContact.name"
:src="currentContact.thumbnail"
:username="currentContact.name"
:size="32"
:status="currentContact.availability_status"
size="32px"
class="flex-shrink-0"
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"

View File

@@ -10,7 +10,7 @@ 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({
@@ -186,10 +186,12 @@ onMounted(() => {
v-if="senderDetails"
class="flex items-center min-w-[15rem] shrink-0"
>
<Thumbnail
<Avatar
v-if="senderDetails.avatar"
:username="senderDetails.name"
: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">

View File

@@ -1,7 +1,7 @@
<script>
// components
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from '../../Avatar.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
@@ -226,18 +226,16 @@ export default {
</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>
@@ -268,11 +266,6 @@ export default {
}
}
.chatwoot-ai-icon {
height: 0.75rem;
width: 0.75rem;
}
.label-suggestion--title {
@apply text-n-slate-11 mt-0.5 text-xxs;
}

View File

@@ -1,12 +1,12 @@
<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,
},
@@ -123,11 +123,13 @@ export default {
</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-n-slate-12">
{{ agent.name }}

View File

@@ -1,76 +0,0 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
label: {
type: String,
default: '',
},
src: {
type: String,
default: '',
},
usernameAvatar: {
type: String,
default: '',
},
deleteAvatar: {
type: Boolean,
default: false,
},
},
emits: ['onAvatarSelect', 'onAvatarDelete'],
watch: {},
methods: {
handleImageUpload(event) {
const [file] = event.target.files;
this.$emit('onAvatarSelect', {
file,
url: file ? URL.createObjectURL(file) : null,
});
},
onAvatarDelete() {
this.$refs.file.value = null;
this.$emit('onAvatarDelete');
},
},
};
</script>
<template>
<div>
<label>
<span v-if="label">{{ label }}</span>
</label>
<woot-thumbnail
v-if="src"
size="80px"
:src="src"
:username="usernameAvatar"
/>
<div v-if="src && deleteAvatar" class="my-1">
<NextButton
outline
xs
ruby
:label="$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT')"
@click="onAvatarDelete"
/>
</div>
<label>
<input
id="file"
ref="file"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<slot />
</label>
</div>
</template>

View File

@@ -139,14 +139,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
export const getInboxIconByType = (type, medium, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
if (type === INBOX_TYPES.TWILIO && medium === 'whatsapp') {
return iconMap[INBOX_TYPES.WHATSAPP];
}

View File

@@ -112,13 +112,13 @@ describe('#Inbox Helpers', () => {
describe('Twilio cases', () => {
describe('fill variant', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-ri-chat-1-fill'
);
});
@@ -133,18 +133,14 @@ describe('#Inbox Helpers', () => {
describe('line variant', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(
INBOX_TYPES.TWILIO,
'whatsapp:+1234567890',
'line'
)
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
).toBe('i-ri-whatsapp-line');
});
it('returns SMS line icon for regular Twilio number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
).toBe('i-ri-chat-1-line');
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line')).toBe(
'i-ri-chat-1-line'
);
});
});
});

View File

@@ -1,38 +1,33 @@
<script>
import Thumbnail from '../../../components/widgets/Thumbnail.vue';
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
Thumbnail,
defineProps({
name: {
type: String,
default: '',
},
props: {
name: {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: [String, Number],
required: true,
},
thumbnail: {
type: String,
default: '',
},
};
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: [String, Number],
required: true,
},
});
</script>
<template>
<div class="option-item--user">
<Thumbnail :src="thumbnail" size="28px" :username="name" />
<Avatar :src="thumbnail" :size="28" :name="name" rounded-full />
<div class="option__user-data">
<h5 class="option__title">
{{ name }}

View File

@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
const props = defineProps({
@@ -40,7 +40,7 @@ const getStatusText = computed(() => {
{{ config.replyTime }}
</div>
</div>
<Thumbnail username="C" size="34px" />
<Avatar name="C" :size="34" rounded-full />
</div>
<button
v-if="config.isDefaultScreen"

View File

@@ -10,10 +10,12 @@ import countries from 'shared/constants/countries.js';
import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
NextButton,
Avatar,
},
props: {
contact: {
@@ -274,18 +276,19 @@ export default {
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div>
<div class="w-full">
<woot-avatar-uploader
:label="$t('CONTACT_FORM.FORM.AVATAR.LABEL')"
:src="avatarUrl"
:username-avatar="name"
:delete-avatar="!!avatarUrl"
class="settings-item"
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">

View File

@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
@@ -24,7 +24,7 @@ export default {
NextButton,
ContactInfoRow,
EditContact,
Thumbnail,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
@@ -179,12 +179,14 @@ export default {
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Thumbnail
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
size="48px"
:username="contact.name"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>

View File

@@ -1,5 +1,5 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -8,7 +8,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Thumbnail,
Avatar,
Spinner,
EmptyState,
NextButton,
@@ -107,11 +107,12 @@ export default {
</span>
</td>
<td class="thumbnail--column">
<Thumbnail
<Avatar
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="28px"
:username="notificationItem.primary_actor.meta.assignee.name"
:size="28"
:name="notificationItem.primary_actor.meta.assignee.name"
rounded-full
/>
</td>
<td>

View File

@@ -1,7 +1,7 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import {
useStoreGetters,
@@ -164,11 +164,13 @@ const confirmDeletion = () => {
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Thumbnail
<Avatar
:src="agent.thumbnail"
:username="agent.name"
size="40px"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
rounded-full
/>
<div>
<span class="block font-medium capitalize">

View File

@@ -2,7 +2,7 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -101,16 +101,20 @@ const openDelete = inbox => {
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
:src="inbox.avatar_url"
:username="inbox.name"
size="48px"
/>
class="bg-n-alpha-3 rounded-full size-12 p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="30"
rounded-full
/>
</div>
<div
v-else
class="w-[48px] h-[48px] flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<ChannelIcon class="size-5" :inbox="inbox" />
</div>

View File

@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
@@ -25,6 +26,7 @@ import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -48,6 +50,7 @@ export default {
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
Avatar,
},
mixins: [inboxMixin],
setup() {
@@ -173,7 +176,10 @@ export default {
inbox() {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxIcon() {
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium);
},
inboxName() {
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
return `${this.inbox.name} (${
@@ -283,8 +289,17 @@ export default {
}
return [...selected, current];
},
refreshAvatarUrlOnTabChange(index) {
// Refresh avatar URL on tab change from inbox_settings and widgetBuilder tabs, to ensure real-time updates
if (
this.inbox &&
['inbox_settings', 'widgetBuilder'].includes(this.tabs[index].key)
)
this.avatarUrl = this.inbox.avatar_url;
},
onTabChange(selectedTabIndex) {
this.selectedTabIndex = selectedTabIndex;
this.refreshAvatarUrlOnTabChange(selectedTabIndex);
},
fetchInboxSettings() {
this.selectedTabIndex = 0;
@@ -435,14 +450,21 @@ export default {
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false"
>
<woot-avatar-uploader
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL')"
:src="avatarUrl"
class="pb-4"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<div class="flex flex-col mb-4 items-start gap-1">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:icon-name="inboxIcon"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="selectedInboxName"
class="pb-4"

View File

@@ -9,6 +9,7 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -17,6 +18,7 @@ export default {
InputRadioGroup,
NextButton,
Editor,
Avatar,
},
props: {
inbox: {
@@ -276,15 +278,23 @@ export default {
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
<woot-avatar-uploader
:label="
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
"
:src="avatarUrl"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
}}
</label>
<Avatar
:src="avatarUrl"
:size="72"
icon-name="i-ri-global-fill"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="websiteName"
:class="{ error: v$.websiteName.$error }"

View File

@@ -1,11 +1,11 @@
<script>
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
PreviewCard,
Thumbnail,
Avatar,
},
props: {
senderNameType: {
@@ -45,7 +45,7 @@ export default {
),
preview: {
senderName: '',
businessName: 'Chatwoot',
businessName: 'Chatwoot ',
email: '<support@yourbusiness.com>',
},
},
@@ -86,7 +86,7 @@ export default {
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FOR_EG') }}
</span>
<div class="flex flex-row items-center gap-2">
<Thumbnail :username="userName(keyOption)" size="32px" />
<Avatar :name="userName(keyOption)" :size="32" rounded-full />
<div class="flex flex-col items-start gap-1">
<div class="items-center flex flex-row gap-0.5 max-w-[18rem]">
<span

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -38,14 +38,14 @@ const visibilityLabel = computed(() => {
<td class="py-4 ltr:pr-4 rtl:pl-4 truncate">{{ macro.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.created_by" class="flex items-center">
<Thumbnail :username="createdByName" size="24px" />
<Avatar :name="createdByName" :size="24" rounded-full />
<span class="mx-2">{{ createdByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.updated_by" class="flex items-center">
<Thumbnail :username="updatedByName" size="24px" />
<Avatar :name="updatedByName" :size="24" rounded-full />
<span class="mx-2">{{ updatedByName }}</span>
</div>
<div v-else>--</div>

View File

@@ -3,7 +3,7 @@ import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
@@ -13,7 +13,7 @@ const CUSTOM_DATE_RANGE_ID = 5;
export default {
components: {
WootDateRangePicker,
Thumbnail,
Avatar,
ToggleSwitch,
},
props: {
@@ -184,11 +184,13 @@ export default {
>
<template #singleLabel="props">
<div class="flex min-w-0 items-center gap-2">
<Thumbnail
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12 truncate">{{
props.option.name
@@ -197,11 +199,13 @@ export default {
</template>
<template #options="props">
<div class="flex items-center gap-2">
<Thumbnail
<Avatar
:src="props.option.thumbnail"
:status="props.option.availability_status"
:username="props.option.name"
size="22px"
:name="props.option.name"
:size="22"
hide-offline-status
rounded-full
/>
<p class="my-0 text-n-slate-12">
{{ props.option.name }}

View File

@@ -1,6 +1,6 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
@@ -19,11 +19,13 @@ const isRTL = useMapGetter('accounts/isRTL');
class="items-center flex text-left"
:class="{ 'flex-row-reverse': isRTL }"
>
<Thumbnail
<Avatar
:src="row.original.thumbnail"
size="32px"
:username="row.original.agent"
:name="row.original.agent"
:status="row.original.status"
:size="32"
hide-offline-status
rounded-full
/>
<div class="items-start flex flex-col min-w-0 my-0 mx-2">
<h6

View File

@@ -1,11 +1,11 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
NextButton,
Thumbnail,
Avatar,
},
props: {
agentList: {
@@ -115,11 +115,13 @@ export default {
</td>
<td>
<div class="flex items-center gap-2">
<Thumbnail
<Avatar
:src="agent.thumbnail"
size="24px"
:username="agent.name"
:name="agent.name"
:status="agent.availability_status"
:size="24"
hide-offline-status
rounded-full
/>
<h4 class="text-base mb-0 text-n-slate-12">
{{ agent.name }}

View File

@@ -4,7 +4,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
@@ -82,12 +82,14 @@ const hasValue = computed(() => {
{{ selectedItem.name }}
</h4>
</div>
<Thumbnail
<Avatar
v-if="hasValue && hasThumbnail"
:src="selectedItem.thumbnail"
size="24px"
:status="selectedItem.availability_status"
:username="selectedItem.name"
:name="selectedItem.name"
:size="24"
hide-offline-status
rounded-full
/>
</Button>
<div

View File

@@ -1,14 +1,14 @@
<script>
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
Thumbnail,
Avatar,
NextButton,
},
@@ -105,13 +105,14 @@ export default {
{{ option.name }}
</span>
</div>
<Thumbnail
<Avatar
v-if="hasThumbnail"
:src="option.thumbnail"
size="24px"
:username="option.name"
:name="option.name"
:status="option.availability_status"
has-border
:size="24"
hide-offline-status
rounded-full
/>
</NextButton>
</WootDropdownItem>

View File

@@ -6,7 +6,7 @@ import { messageStamp } from 'shared/helpers/timeHelper';
import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FileBubble from 'widget/components/FileBubble.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
@@ -21,7 +21,7 @@ export default {
AgentMessageBubble,
ImageBubble,
VideoBubble,
Thumbnail,
Avatar,
UserMessage,
FileBubble,
MessageReplyButton,
@@ -165,12 +165,15 @@ export default {
>
<div v-if="!isASubmittedForm" class="agent-message">
<div class="avatar-wrap">
<Thumbnail
v-if="message.showAvatar || hasRecordedResponse"
:src="avatarUrl"
size="24px"
:username="agentName"
/>
<div class="user-thumbnail-box">
<Avatar
v-if="message.showAvatar || hasRecordedResponse"
:src="avatarUrl"
:size="24"
:name="agentName"
rounded-full
/>
</div>
</div>
<div class="message-wrap">
<div v-if="hasReplyTo" class="flex mt-2 mb-1 text-xs">

View File

@@ -1,5 +1,5 @@
<script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { defineProps, computed } from 'vue';
const props = defineProps({
@@ -24,11 +24,12 @@ const usersToDisplay = computed(() => props.users.slice(0, props.limit));
:class="index ? 'ltr:-ml-4 rtl:-mr-4' : ''"
class="inline-block rounded-full text-white shadow-solid"
>
<Thumbnail
size="36px"
:username="user.name"
<Avatar
:name="user.name"
:src="user.avatar_url"
has-border
:size="36"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background"
rounded-full
/>
</span>
</div>

View File

@@ -1,6 +1,6 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import configMixin from '../mixins/configMixin';
import { isEmptyObject } from 'widget/helpers/utils';
import {
@@ -11,7 +11,7 @@ import { emitter } from 'shared/helpers/mitt';
export default {
name: 'UnreadMessage',
components: { Thumbnail },
components: { Avatar },
mixins: [configMixin],
props: {
message: {
@@ -95,11 +95,12 @@ export default {
<div class="chat-bubble-wrap">
<button class="chat-bubble agent bg-white" @click="onClickMessage">
<div v-if="showSender" class="row--agent-block">
<Thumbnail
<Avatar
:src="avatarUrl"
size="20px"
:username="agentName"
:size="20"
:name="agentName"
:status="availabilityStatus"
rounded-full
/>
<span v-dompurify-html="agentName" class="agent--name" />
<span v-dompurify-html="companyName" class="company--name" />