chore: Update contacts page
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const props = defineProps({
|
||||
customAttributes: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const displayAttributes = computed(() => {
|
||||
const selectedKeys = uiSettings.value.contact_list_display_properties || [];
|
||||
if (!selectedKeys.length) return [];
|
||||
|
||||
return contactAttributes.value
|
||||
.filter(
|
||||
attr =>
|
||||
selectedKeys.includes(attr.attributeKey) &&
|
||||
attr.attributeKey in props.customAttributes
|
||||
)
|
||||
.map(attribute => ({
|
||||
attribute,
|
||||
value: props.customAttributes[attribute.attributeKey],
|
||||
}));
|
||||
});
|
||||
|
||||
const formatValue = (value, displayType) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
switch (displayType) {
|
||||
case 'checkbox':
|
||||
return value
|
||||
? t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.YES')
|
||||
: t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO');
|
||||
case 'list':
|
||||
return Array.isArray(value) ? value.join(', ') : value;
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString();
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-for="(item, index) in displayAttributes"
|
||||
:key="item.attribute.attributeKey"
|
||||
>
|
||||
<div
|
||||
v-if="index === 0"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: `${item.attribute.attributeDisplayName}: ${formatValue(item.value, item.attribute.attributeDisplayType)}`,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate"
|
||||
>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ item.attribute.attributeDisplayName }}{{ ':' }}
|
||||
</span>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ formatValue(item.value, item.attribute.attributeDisplayType) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < displayAttributes.length - 1"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
+330
-279
@@ -1,16 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, email as emailValidator } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
|
||||
import CountryDropdown from 'dashboard/components-next/Countries/CountryDropdown.vue';
|
||||
import CompaniesDropdown from 'dashboard/components-next/Companies/CompaniesDropdown.vue';
|
||||
import AddContactNote from './AddContactNote.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardContent from 'dashboard/components-next/Conversation/ConversationCard/CardContent.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import CardStatusIcon from 'dashboard/components-next/Conversation/ConversationCard/CardStatusIcon.vue';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
@@ -19,301 +22,349 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-woot-linkedin',
|
||||
FACEBOOK: 'i-woot-facebook',
|
||||
INSTAGRAM: 'i-woot-instagram',
|
||||
TWITTER: 'i-woot-x',
|
||||
GITHUB: 'i-woot-github',
|
||||
};
|
||||
const contactNotes = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const contactConversations = useMapGetter(
|
||||
'contactConversations/getAllConversationsByContactId'
|
||||
);
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const agentsList = useMapGetter('agents/getAgents');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const formState = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
city: '',
|
||||
countryCode: '',
|
||||
bio: '',
|
||||
companyName: '',
|
||||
companyId: null,
|
||||
socialProfiles: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
firstName: { required },
|
||||
email: { email: emailValidator },
|
||||
};
|
||||
const countryDetails = computed(() => {
|
||||
const attributes = props.contactData?.additionalAttributes || {};
|
||||
const { country, countryCode, city } = attributes;
|
||||
|
||||
const v$ = useVuelidate(validationRules, formState);
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||
const activeCountry =
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
if (!activeCountry) return null;
|
||||
|
||||
const prepareStateBasedOnProps = () => {
|
||||
const {
|
||||
name = '',
|
||||
email: emailAddress = '',
|
||||
phoneNumber: phone = '',
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
|
||||
const { firstName: fName, lastName: lName } = splitName(name || '');
|
||||
const {
|
||||
description = '',
|
||||
countryCode: country = '',
|
||||
city: cityName = '',
|
||||
socialProfiles: profiles = {},
|
||||
companyName: company = '',
|
||||
} = additionalAttributes || {};
|
||||
|
||||
formState.firstName = fName;
|
||||
formState.lastName = lName;
|
||||
formState.email = emailAddress;
|
||||
formState.phoneNumber = phone || '';
|
||||
formState.city = cityName;
|
||||
formState.countryCode = country;
|
||||
formState.bio = description;
|
||||
formState.companyName = company;
|
||||
formState.socialProfiles = { ...formState.socialProfiles, ...profiles };
|
||||
};
|
||||
|
||||
const socialProfilesForm = computed(() =>
|
||||
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
|
||||
key,
|
||||
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
|
||||
icon,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleCompanyChange = company => {
|
||||
formState.companyName = company?.name || '';
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
const contactData = {
|
||||
name: `${formState.firstName} ${formState.lastName}`.trim(),
|
||||
email: formState.email,
|
||||
phoneNumber: formState.phoneNumber,
|
||||
additionalAttributes: {
|
||||
description: formState.bio,
|
||||
city: formState.city,
|
||||
countryCode: formState.countryCode,
|
||||
companyName: formState.companyName,
|
||||
socialProfiles: formState.socialProfiles,
|
||||
},
|
||||
return {
|
||||
countryCode: activeCountry.id,
|
||||
city: city ? `${city},` : null,
|
||||
name: activeCountry.name,
|
||||
};
|
||||
|
||||
emit('update', contactData);
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
city: '',
|
||||
countryCode: '',
|
||||
bio: '',
|
||||
companyName: '',
|
||||
companyId: null,
|
||||
socialProfiles: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactData?.id,
|
||||
id => {
|
||||
if (id) prepareStateBasedOnProps();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
resetValidation,
|
||||
isFormInvalid,
|
||||
resetForm,
|
||||
handleUpdate,
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
return [countryDetails.value.city, countryDetails.value.name]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const companyName = computed(() => {
|
||||
const attributes = props.contactData?.additionalAttributes || {};
|
||||
return attributes.companyName || '';
|
||||
});
|
||||
|
||||
const contactLabels = computed(() => {
|
||||
return props.contactData?.labels || [];
|
||||
});
|
||||
|
||||
const latestNote = computed(() => {
|
||||
if (!props.contactData?.id) return null;
|
||||
const notes = contactNotes.value(props.contactData.id);
|
||||
return notes && notes.length > 0 ? notes[0] : null;
|
||||
});
|
||||
|
||||
const latestConversation = computed(() => {
|
||||
if (!props.contactData?.id) return null;
|
||||
const conversations = contactConversations.value(props.contactData.id);
|
||||
return conversations && conversations.length > 0 ? conversations[0] : null;
|
||||
});
|
||||
|
||||
const lastMessageInConversation = computed(() => {
|
||||
if (!latestConversation.value) return null;
|
||||
return getLastMessage(latestConversation.value);
|
||||
});
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = latestConversation.value?.inboxId;
|
||||
return inboxId ? inboxGetter.value(inboxId) : {};
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
if (!latestConversation.value?.meta?.assignee?.id) return null;
|
||||
return agentsList.value?.find(
|
||||
agent => agent.id === latestConversation.value.meta.assignee.id
|
||||
);
|
||||
});
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
return user?.id === currentUserId
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
const formatTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
return dynamicTime(timestamp);
|
||||
};
|
||||
|
||||
const getConversationUrl = conversationId => {
|
||||
if (!conversationId || !currentAccountId.value) return '#';
|
||||
const url = `/app/accounts/${currentAccountId.value}/conversations/${conversationId}`;
|
||||
return router.push(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<AddContactNote :contact-id="contactData?.id" />
|
||||
|
||||
<div class="flex flex-col items-start gap-4 pt-2 pb-3">
|
||||
<span
|
||||
class="py-1 text-heading-3 text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-settings-2"
|
||||
class="size-4 text-n-slate-11 hidden lg:block"
|
||||
<div
|
||||
class="flex flex-col gap-2 ltr:pl-2 rtl:pr-2 lg:ltr:pl-9 lg:rtl:pr-9 lg:before:content-[''] lg:before:absolute lg:before:ltr:left-[2.688rem] lg:before:rtl:right-[2.688rem] lg:before:top-0 lg:before:w-px lg:before:h-4 lg:before:bg-n-weak"
|
||||
>
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<Icon icon="i-woot-overview" class="size-4 text-n-slate-11" />
|
||||
<h3 class="text-n-slate-11 text-body-main">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Section 1: Basic Information -->
|
||||
<div
|
||||
class="hidden lg:flex rounded-xl bg-n-card w-auto outline outline-1 outline-n-container -outline-offset-1 p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon icon="i-lucide-circle-user" class="size-4 text-n-slate-11" />
|
||||
<span class="text-label-small text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.BASIC_INFO') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contactData?.email"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="lg:grid lg:grid-cols-[1fr_auto_1fr] flex flex-col items-start lg:items-center w-full gap-3 lg:px-6"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2 min-w-0 w-full">
|
||||
<Input
|
||||
v-model="formState.firstName"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.FIRST_NAME.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:message-type="v$.firstName.$error ? 'error' : 'info'"
|
||||
class="h-8 min-w-0"
|
||||
@input="v$.firstName.$touch()"
|
||||
@blur="v$.firstName.$touch()"
|
||||
/>
|
||||
<Input
|
||||
v-model="formState.lastName"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.LAST_NAME.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="h-8 min-w-0"
|
||||
<!-- Email -->
|
||||
<div v-if="contactData?.email" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-woot-mail"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ contactData.email }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-strong hidden lg:block" />
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 min-w-0 w-full">
|
||||
<CompaniesDropdown
|
||||
v-model="formState.companyId"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.COMPANY_NAME.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:selected-company-name="formState.companyName"
|
||||
class="min-w-0"
|
||||
@change="handleCompanyChange"
|
||||
/>
|
||||
<Input
|
||||
v-model="formState.city"
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.CITY.PLACEHOLDER')
|
||||
"
|
||||
class="h-8 min-w-0"
|
||||
/>
|
||||
<CountryDropdown
|
||||
v-model="formState.countryCode"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.COUNTRY.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="lg:grid lg:grid-cols-[1fr_auto_1fr] flex flex-col lg:items-center w-full gap-3 lg:px-6"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2 min-w-0 w-full">
|
||||
<Input
|
||||
v-model="formState.email"
|
||||
type="email"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:message-type="v$.email.$error ? 'error' : 'info'"
|
||||
class="h-8 min-w-0 [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8"
|
||||
@input="v$.email.$touch()"
|
||||
@blur="v$.email.$touch()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-woot-mail"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2.5 rtl:right-2.5"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
<PhoneNumberInput
|
||||
v-model="formState.phoneNumber"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.PHONE_NUMBER.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-strong hidden lg:block" />
|
||||
|
||||
<Input
|
||||
v-model="formState.bio"
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.BIO.PLACEHOLDER')
|
||||
"
|
||||
class="h-8 min-w-0"
|
||||
<div
|
||||
v-if="contactData?.phoneNumber"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<!-- Phone Number -->
|
||||
<div v-if="contactData?.phoneNumber" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-phone"
|
||||
class="size-3.5 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ contactData.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="companyName" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<!-- Company Name -->
|
||||
<div v-if="companyName" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-briefcase-business"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ companyName }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="countryDetails" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<!-- Location (Country & City) -->
|
||||
<div v-if="countryDetails" class="flex items-center gap-1">
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contactLabels.length > 0"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<!-- Labels -->
|
||||
|
||||
<div v-if="contactData?.id" class="lg:px-6">
|
||||
<ContactLabels :contact-id="contactData.id" />
|
||||
<ContactLabels
|
||||
v-if="contactLabels.length > 0"
|
||||
:labels="contactLabels"
|
||||
disable-toggle
|
||||
class="my-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4 py-2">
|
||||
<span
|
||||
class="py-1 text-heading-3 text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-globe"
|
||||
class="size-4 text-n-slate-11 hidden lg:block"
|
||||
<!-- Section 2: Latest Contact Note -->
|
||||
<div
|
||||
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-notebook-pen" class="size-3.5 text-n-slate-11" />
|
||||
<span class="text-label-small text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_NOTE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="latestNote">
|
||||
<div class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<span class="text-label-small text-n-slate-11">
|
||||
{{ formatTimestamp(latestNote.createdAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="latestNote" class="flex items-center gap-1.5">
|
||||
<Avatar
|
||||
v-tooltip.left="{
|
||||
content: getWrittenBy(latestNote),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="latestNote?.user?.name || 'Bot'"
|
||||
:src="
|
||||
latestNote?.user?.name
|
||||
? latestNote?.user?.thumbnail
|
||||
: '/assets/images/chatwoot_bot.png'
|
||||
"
|
||||
:size="14"
|
||||
rounded-full
|
||||
/>
|
||||
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2 lg:px-6">
|
||||
<div
|
||||
v-for="item in socialProfilesForm"
|
||||
:key="item.key"
|
||||
class="flex items-center h-8 gap-2 px-2 rounded-lg bg-n-alpha-2 outline-1 outline outline-n-weak -outline-offset-1 dark:bg-n-solid-2 hover:outline-n-slate-6 transition-all duration-150"
|
||||
>
|
||||
<Icon
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-5"
|
||||
<p class="text-body-main text-n-slate-12 m-0 line-clamp-1">
|
||||
{{ latestNote.content }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-body-para text-n-slate-10 m-0 italic">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.NO_NOTES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Latest Previous Conversation -->
|
||||
<div
|
||||
v-if="latestConversation"
|
||||
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5 cursor-pointer"
|
||||
@click="getConversationUrl(latestConversation.id)"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-2 w-full min-w-0"
|
||||
:class="
|
||||
latestConversation?.labels?.length > 0
|
||||
? 'sm:flex-row flex-col items-start sm:items-center '
|
||||
: 'flex-row items-center'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-1 min-w-0 flex-shrink">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-message-circle"
|
||||
class="size-3.5 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-label-small text-n-slate-11 m-0 truncate">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_CONVERSATION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="inbox?.id" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<InboxName
|
||||
v-if="inbox?.id"
|
||||
:inbox="inbox"
|
||||
class="gap-1 [&>span]:text-n-slate-11"
|
||||
/>
|
||||
<div
|
||||
v-if="latestConversation?.id"
|
||||
class="flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-label-small truncate text-n-slate-11">
|
||||
{{ latestConversation.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="latestConversation?.createdAt"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<input
|
||||
v-model="formState.socialProfiles[item.key.toLowerCase()]"
|
||||
class="w-auto min-w-[100px] text-sm bg-transparent outline-none reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
|
||||
:placeholder="item.placeholder"
|
||||
:size="item.placeholder.length"
|
||||
<span
|
||||
v-if="latestConversation?.updatedAt"
|
||||
class="text-label-small text-n-slate-11 truncate min-w-0"
|
||||
>
|
||||
{{ formatTimestamp(latestConversation.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<ContactLabels
|
||||
v-if="latestConversation?.labels?.length > 0"
|
||||
:labels="latestConversation.labels"
|
||||
class="flex-1 min-w-0 sm:justify-end w-full sm:w-auto"
|
||||
disable-toggle
|
||||
>
|
||||
<template #before>
|
||||
<div data-before-slot class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon
|
||||
v-if="latestConversation.priority"
|
||||
:priority="latestConversation.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="assignee?.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon
|
||||
v-if="latestConversation.status"
|
||||
:status="latestConversation.status"
|
||||
/>
|
||||
<div class="h-3 w-px bg-n-weak rounded-lg mx-1" />
|
||||
</div>
|
||||
</template>
|
||||
</ContactLabels>
|
||||
<div v-else class="flex items-center justify-end gap-1.5 flex-1">
|
||||
<CardPriorityIcon
|
||||
v-if="latestConversation.priority"
|
||||
:priority="latestConversation.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="assignee?.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon
|
||||
v-if="latestConversation.status"
|
||||
:status="latestConversation.status"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent
|
||||
v-if="lastMessageInConversation"
|
||||
:last-message="lastMessageInConversation"
|
||||
:unread-count="lastMessageInConversation?.conversation?.unreadCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
|
||||
import ContactCardForm from 'dashboard/components-next/Contacts/ContactsCard/ContactCardForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -10,6 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import ContactAttributeDisplay from './ContactAttributeDisplay.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -18,28 +20,27 @@ const props = defineProps({
|
||||
email: { type: String, default: '' },
|
||||
labels: { type: Array, default: () => [] },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
customAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'updateContact',
|
||||
'showContact',
|
||||
'select',
|
||||
'avatarHover',
|
||||
'sendMessage',
|
||||
'deleteContact',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const contactCardFormRef = ref(null);
|
||||
const isPrefetching = ref(false);
|
||||
const hasPrefetched = ref(false);
|
||||
|
||||
const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
@@ -47,12 +48,11 @@ const getInitialContactData = () => ({
|
||||
email: props.email,
|
||||
phoneNumber: props.phoneNumber,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
labels: props.labels,
|
||||
});
|
||||
|
||||
const contactData = ref(getInitialContactData());
|
||||
|
||||
const isFormInvalid = computed(() => contactCardFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
@@ -92,15 +92,6 @@ const formattedLocation = computed(() => {
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const handleFormUpdate = updatedData => {
|
||||
Object.assign(contactData.value, updatedData);
|
||||
emit('updateContact', contactData.value);
|
||||
};
|
||||
|
||||
const handleUpdateContact = () => {
|
||||
contactCardFormRef.value?.handleUpdate();
|
||||
};
|
||||
|
||||
const onClickExpand = () => {
|
||||
emit('toggle');
|
||||
contactData.value = getInitialContactData();
|
||||
@@ -118,23 +109,51 @@ const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
|
||||
const handleAvatarHover = isHovered => {
|
||||
emit('avatarHover', isHovered);
|
||||
const prefetchContactData = async () => {
|
||||
if (hasPrefetched.value || isPrefetching.value || !props.id) return;
|
||||
|
||||
isPrefetching.value = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
store.dispatch('contactNotes/get', { contactId: props.id }),
|
||||
store.dispatch('contactConversations/get', props.id),
|
||||
]);
|
||||
hasPrefetched.value = true;
|
||||
} catch (error) {
|
||||
// error
|
||||
} finally {
|
||||
isPrefetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandHover = isHovered => {
|
||||
if (isHovered && !props.isExpanded) {
|
||||
prefetchContactData();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-col gap-2 pt-3 pb-2 lg:pb-3 lg:grid lg:gap-4 lg:items-center lg:rounded-lg lg:transition-all lg:duration-200 lg:grid-cols-[minmax(42%,1fr)_minmax(0,1fr)_minmax(0,1fr)]"
|
||||
class="flex flex-col gap-2 p-2 lg:grid lg:gap-4 lg:items-center lg:rounded-lg lg:transition-all lg:duration-200 lg:grid-cols-[minmax(30%,1fr)_minmax(50%,1.5fr)_1fr]"
|
||||
:class="{ 'border-b border-n-weak lg:border-none': isExpanded }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 lg:gap-2"
|
||||
:class="{ 'lg:col-span-3': isExpanded }"
|
||||
>
|
||||
<div class="flex-shrink-0 size-5 flex items-center justify-center">
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative hidden lg:block size-5 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
|
||||
class="relative hidden lg:block size-4 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
|
||||
@mouseenter.passive="handleExpandHover(true)"
|
||||
@mouseleave.passive="handleExpandHover(false)"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -142,37 +161,20 @@ const handleAvatarHover = isHovered => {
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
class="flex-shrink-0 !size-8 absolute -inset-1.5"
|
||||
class="flex-shrink-0 !size-8 absolute -inset-2"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-shrink-0 flex items-center"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="24"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4
|
||||
@@ -229,7 +231,7 @@ const handleAvatarHover = isHovered => {
|
||||
|
||||
<div
|
||||
v-if="companyName"
|
||||
class="my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 max-w-40 min-w-0 outline outline-1 outline-n-weak"
|
||||
class="my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 max-w-40 min-w-0 bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<Icon
|
||||
@@ -239,26 +241,10 @@ const handleAvatarHover = isHovered => {
|
||||
<span class="truncate text-label-small">{{ companyName }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="countryDetails"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 min-w-0 text-n-slate-11"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate text-label-small">{{ formattedLocation }}</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="relative lg:hidden size-5 rounded-md hover:bg-n-alpha-2 ltr:ml-auto rtl:mr-auto flex-shrink-0"
|
||||
@mouseenter.passive="handleExpandHover(true)"
|
||||
@mouseleave.passive="handleExpandHover(false)"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -315,6 +301,29 @@ const handleAvatarHover = isHovered => {
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="countryDetails"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="countryDetails"
|
||||
v-tooltip.top="{
|
||||
content: formattedLocation,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-2 min-w-0 text-n-slate-11"
|
||||
>
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
</div>
|
||||
<ContactAttributeDisplay :custom-attributes="customAttributes" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:hidden">
|
||||
@@ -341,14 +350,6 @@ const handleAvatarHover = isHovered => {
|
||||
disable-toggle
|
||||
class="my-0 flex-1 justify-end"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
link
|
||||
xs
|
||||
slate
|
||||
class="flex-shrink-0 hover:!no-underline"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -361,26 +362,10 @@ const handleAvatarHover = isHovered => {
|
||||
>
|
||||
<div class="min-h-0">
|
||||
<div
|
||||
class="relative flex flex-col lg:pt-3 pb-3 overflow-visible transition-opacity duration-[600ms] ease-out"
|
||||
class="relative flex flex-col pt-3 pb-4 lg:pt-[1.125rem] lg:pb-5 overflow-visible transition-opacity duration-[600ms] ease-out"
|
||||
:class="isExpanded ? 'opacity-100 delay-200' : 'opacity-0'"
|
||||
>
|
||||
<ContactCardForm
|
||||
ref="contactCardFormRef"
|
||||
:contact-data="contactData"
|
||||
class="lg:after:content-[''] lg:after:absolute lg:after:ltr:left-2 lg:after:rtl:right-2 lg:after:top-0 lg:after:w-px lg:after:bg-n-strong lg:after:bottom-11"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div
|
||||
class="relative lg:ltr:pl-6 lg:rtl:pr-6 mt-6 mb-4 lg:before:block lg:before:content-[''] lg:before:absolute lg:ltr:before:left-2 lg:rtl:before:right-2 lg:before:top-1/2 lg:before:w-2 lg:before:h-px lg:before:bg-n-strong"
|
||||
>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
<ContactCardForm :contact-data="contactData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import DisplayPropertiesModal from './components/DisplayPropertiesModal.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
@@ -29,12 +31,18 @@ const emit = defineEmits([
|
||||
'createSegment',
|
||||
'deleteSegment',
|
||||
]);
|
||||
|
||||
const displayPropertiesModalRef = ref(null);
|
||||
|
||||
const openDisplayPropertiesModal = () => {
|
||||
displayPropertiesModalRef.value?.dialogRef?.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[105rem] after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
|
||||
class="flex items-start sm:items-center justify-between w-full py-4 px-6 gap-2 mx-auto after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
|
||||
>
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
@@ -61,6 +69,13 @@ const emit = defineEmits([
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="i-lucide-settings-2"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="openDisplayPropertiesModal"
|
||||
/>
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
@@ -121,5 +136,6 @@ const emit = defineEmits([
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DisplayPropertiesModal ref="displayPropertiesModalRef" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const MAX_SELECTED = 3;
|
||||
|
||||
const selectedAttributeKeys = computed({
|
||||
get: () => uiSettings.value?.contact_list_display_properties || [],
|
||||
set: value => {
|
||||
updateUISettings({
|
||||
contact_list_display_properties: value,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selectedAttributes = computed(() => {
|
||||
return contactAttributes.value.filter(attr =>
|
||||
selectedAttributeKeys.value.includes(attr.attributeKey)
|
||||
);
|
||||
});
|
||||
|
||||
const otherAttributes = computed(() => {
|
||||
return contactAttributes.value.filter(
|
||||
attr => !selectedAttributeKeys.value.includes(attr.attributeKey)
|
||||
);
|
||||
});
|
||||
|
||||
const canSelectMore = computed(() => {
|
||||
return selectedAttributeKeys.value.length < MAX_SELECTED;
|
||||
});
|
||||
|
||||
const toggleAttribute = attributeKey => {
|
||||
const currentKeys = [...selectedAttributeKeys.value];
|
||||
const index = currentKeys.indexOf(attributeKey);
|
||||
|
||||
if (index > -1) {
|
||||
currentKeys.splice(index, 1);
|
||||
} else if (currentKeys.length < MAX_SELECTED) {
|
||||
currentKeys.push(attributeKey);
|
||||
}
|
||||
|
||||
selectedAttributeKeys.value = currentKeys;
|
||||
};
|
||||
|
||||
const removeAttribute = attributeKey => {
|
||||
const currentKeys = [...selectedAttributeKeys.value];
|
||||
const index = currentKeys.indexOf(attributeKey);
|
||||
if (index > -1) {
|
||||
currentKeys.splice(index, 1);
|
||||
selectedAttributeKeys.value = currentKeys;
|
||||
}
|
||||
};
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const handleClose = () => {
|
||||
// Close the dialog after saving
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.TITLE')"
|
||||
:confirm-button-label="t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.SAVE')"
|
||||
:cancel-button-label="t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.CANCEL')"
|
||||
width="2xl"
|
||||
@confirm="handleClose"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<p class="text-body-main text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<div v-if="selectedAttributes.length > 0" class="flex flex-col gap-3">
|
||||
<h3 class="text-heading-3 text-n-slate-12 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.SELECTED_ATTRIBUTES') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="attribute in selectedAttributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-n-brand-alpha text-n-brand border border-n-brand"
|
||||
>
|
||||
<Checkbox
|
||||
model-value
|
||||
@change="removeAttribute(attribute.attributeKey)"
|
||||
/>
|
||||
<span class="text-label-main">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center justify-center size-4 rounded hover:bg-n-brand-alpha-hover transition-colors"
|
||||
@click="removeAttribute(attribute.attributeKey)"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="otherAttributes.length > 0" class="flex flex-col gap-3">
|
||||
<h3 class="text-heading-3 text-n-slate-12 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.OTHER_ATTRIBUTES') }}
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="attribute in otherAttributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md transition-colors"
|
||||
:class="[
|
||||
canSelectMore
|
||||
? 'bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer border border-n-weak'
|
||||
: 'bg-n-alpha-1 border border-n-weak opacity-50 cursor-not-allowed',
|
||||
]"
|
||||
@click="canSelectMore && toggleAttribute(attribute.attributeKey)"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="false"
|
||||
:disabled="!canSelectMore"
|
||||
@change="toggleAttribute(attribute.attributeKey)"
|
||||
/>
|
||||
<span class="text-label-main text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="attribute.attributeDescription"
|
||||
v-tooltip.top="{
|
||||
content: attribute.attributeDescription,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5 text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contactAttributes.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-inbox"
|
||||
class="size-12 text-n-slate-11 opacity-50"
|
||||
/>
|
||||
<p class="text-body-main text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -30,7 +30,6 @@ const route = useRoute();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
const hoveredAvatarId = ref(null);
|
||||
const selectedContactId = ref(null);
|
||||
const composeConversationRef = ref(null);
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
@@ -88,18 +87,10 @@ const toggleExpanded = async id => {
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
|
||||
const shouldShowSelection = id => {
|
||||
return hoveredAvatarId.value === id || isSelected(id);
|
||||
};
|
||||
|
||||
const handleSelect = (id, value) => {
|
||||
emit('toggleContact', { id, value });
|
||||
};
|
||||
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleSendMessage = id => {
|
||||
selectedContactId.value = String(id);
|
||||
composeConversationRef.value?.toggle();
|
||||
@@ -124,17 +115,16 @@ const handleDeleteContact = id => {
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:custom-attributes="contact.customAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
:selectable="shouldShowSelection(contact.id)"
|
||||
:is-selected="isSelected(contact.id)"
|
||||
:labels="contact.labels || []"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
@select="value => handleSelect(contact.id, value)"
|
||||
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||
@send-message="handleSendMessage"
|
||||
@delete-contact="handleDeleteContact"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -37,6 +37,6 @@ const iconName = computed(() => {
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -58,28 +58,37 @@ const bulkCheckboxState = computed({
|
||||
const animationClasses = computed(() => {
|
||||
if (props.animationDirection === 'vertical') {
|
||||
return {
|
||||
enterFrom: 'opacity-0 transform -translate-y-4',
|
||||
enterTo: 'opacity-100 transform translate-y-0',
|
||||
enterActive: 'transition-all duration-200 ease-out origin-bottom',
|
||||
enterFrom: 'opacity-0 scale-95 translate-y-2',
|
||||
enterTo: 'opacity-100 scale-100 translate-y-0',
|
||||
leaveActive: 'transition-all duration-150 ease-in origin-bottom',
|
||||
leaveFrom: 'opacity-100 scale-100 translate-y-0',
|
||||
leaveTo: 'opacity-0 scale-95 translate-y-2',
|
||||
};
|
||||
}
|
||||
return {
|
||||
enterFrom: 'opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4',
|
||||
enterTo: 'opacity-100 transform translate-x-0',
|
||||
enterActive: 'transition-all duration-300 ease-out',
|
||||
enterFrom: 'opacity-0 ltr:-translate-x-4 rtl:translate-x-4',
|
||||
enterTo: 'opacity-100 translate-x-0',
|
||||
leaveActive: 'transition-all duration-200 ease-in',
|
||||
leaveFrom: 'opacity-100 translate-x-0',
|
||||
leaveTo: 'opacity-0 ltr:-translate-x-4 rtl:translate-x-4',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
<Transition
|
||||
:enter-active-class="animationClasses.enterActive"
|
||||
:enter-from-class="animationClasses.enterFrom"
|
||||
:enter-to-class="animationClasses.enterTo"
|
||||
leave-active-class="hidden opacity-0"
|
||||
:leave-active-class="animationClasses.leaveActive"
|
||||
:leave-from-class="animationClasses.leaveFrom"
|
||||
:leave-to-class="animationClasses.leaveTo"
|
||||
>
|
||||
<div
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-2 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1 shadow-sm"
|
||||
class="flex items-center gap-3 py-2 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1 shadow-sm origin-bottom"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
@@ -116,5 +125,5 @@ const animationClasses = computed(() => {
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<slot name="default-actions" />
|
||||
</div>
|
||||
</transition>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -466,7 +466,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
|
||||
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||
@@ -519,13 +519,13 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
@delete-folder="chatListFiltersRef?.onClickOpenDeleteFoldersModal"
|
||||
/>
|
||||
<ConversationBulkActions
|
||||
v-if="selectedConversations.length"
|
||||
:conversations="selectedConversations"
|
||||
:all-conversations-selected="allConversationsSelected"
|
||||
:selected-inboxes="uniqueInboxes"
|
||||
:show-open-action="allSelectedConversationsStatus('open')"
|
||||
:show-resolved-action="allSelectedConversationsStatus('resolved')"
|
||||
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
|
||||
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
|
||||
@select-all-conversations="toggleSelectAll"
|
||||
/>
|
||||
|
||||
|
||||
+3
-3
@@ -113,10 +113,10 @@ const handleToggleDropdown = () => {
|
||||
@click="handleToggleDropdown"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
@@ -130,7 +130,7 @@ const handleToggleDropdown = () => {
|
||||
:is-loading="isLoading"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-60 max-h-80 overflow-y-auto"
|
||||
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectAgent"
|
||||
>
|
||||
<template #footer>
|
||||
|
||||
+6
-6
@@ -85,7 +85,7 @@ const handleAssign = () => {
|
||||
ghost
|
||||
:class="{
|
||||
'bg-n-alpha-2': showDropdown,
|
||||
'[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit':
|
||||
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit':
|
||||
isTypeContact,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
@@ -93,10 +93,10 @@ const handleAssign = () => {
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
@@ -109,11 +109,11 @@ const handleAssign = () => {
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="top-8 w-60 max-h-80 overflow-y-auto"
|
||||
class="bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
:class="{
|
||||
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0':
|
||||
!isTypeContact,
|
||||
'ltr:right-0 rtl:left-0 mt-1': isTypeContact,
|
||||
'ltr:right-0 rtl:left-0 mb-1': isTypeContact,
|
||||
}"
|
||||
@action="item => toggleLabelSelection(item.value)"
|
||||
>
|
||||
@@ -138,7 +138,7 @@ const handleAssign = () => {
|
||||
>
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full"
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
|
||||
+3
-3
@@ -83,10 +83,10 @@ onMounted(() => {
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
@@ -99,7 +99,7 @@ onMounted(() => {
|
||||
:menu-items="teamMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="ltr:-right-2 rtl:-left-2 top-8 w-60 max-h-80 overflow-y-auto"
|
||||
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80 overflow-y-auto"
|
||||
@action="handleSelectTeam"
|
||||
>
|
||||
<template #footer>
|
||||
|
||||
+3
-3
@@ -87,10 +87,10 @@ const handleUpdate = item => {
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150 ease-out origin-top"
|
||||
enter-active-class="transition-all duration-150 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-top"
|
||||
leave-active-class="transition-all duration-100 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
@@ -101,7 +101,7 @@ const handleUpdate = item => {
|
||||
{ ignore: [containerRef] },
|
||||
]"
|
||||
:menu-items="updateMenuItems"
|
||||
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 top-8 w-36"
|
||||
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
|
||||
@action="handleUpdate"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
+67
-57
@@ -106,68 +106,78 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pt-3 pb-2 px-2 relative z-10 after:absolute after:inset-x-0 after:-bottom-2.5 after:bg-gradient-to-b after:from-n-surface-1 after:from-40% after:to-transparent after:h-4 after:pointer-events-none after:z-10"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out origin-bottom"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in origin-bottom"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak"
|
||||
v-show="conversations.length > 0"
|
||||
class="px-2 absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
|
||||
>
|
||||
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
|
||||
<label class="cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
:indeterminate="!allConversationsSelected"
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
|
||||
>
|
||||
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
|
||||
<label class="cursor-pointer flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="allSelected"
|
||||
:indeterminate="!allConversationsSelected"
|
||||
/>
|
||||
<span class="cursor-pointer">
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
|
||||
<NextButton
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
<span class="cursor-pointer">
|
||||
{{
|
||||
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
|
||||
conversationCount: conversations.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
|
||||
<NextButton
|
||||
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
|
||||
ghost
|
||||
class="!text-n-blue-11 !px-1 !h-6"
|
||||
sm
|
||||
@click="allSelected = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="onUpdateConversations"
|
||||
/>
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
:show-snooze="!showSnoozedAction"
|
||||
@update="onUpdateConversations"
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
<BulkAgentActions
|
||||
:selected-inboxes="selectedInboxes"
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignAgent"
|
||||
/>
|
||||
<BulkTeamActions
|
||||
:conversation-count="conversations.length"
|
||||
@select="onAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
<div
|
||||
v-if="allConversationsSelected"
|
||||
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mt-2 py-1.5 px-2 text-n-amber-text"
|
||||
>
|
||||
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
|
||||
</div>
|
||||
<woot-modal
|
||||
v-model:show="showCustomTimeSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<CustomSnoozeModal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="customSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -388,6 +388,17 @@
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
},
|
||||
"DISPLAY_PROPERTIES": {
|
||||
"TITLE": "Display Properties",
|
||||
"DESCRIPTION": "Select up to three contact attributes to display.",
|
||||
"SELECTED_ATTRIBUTES": "Selected attributes",
|
||||
"OTHER_ATTRIBUTES": "Other attributes",
|
||||
"NO_ATTRIBUTES": "No contact attributes available",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVE": "Save",
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
},
|
||||
"FILTER": {
|
||||
"NAME": "Name",
|
||||
"EMAIL": "Email",
|
||||
@@ -471,6 +482,14 @@
|
||||
"LABELS": {
|
||||
"ADD_BUTTON": "Add Labels"
|
||||
},
|
||||
"QUICK_OVERVIEW": {
|
||||
"TITLE": "Quick Overview",
|
||||
"BASIC_INFO": "Basic Info",
|
||||
"LATEST_NOTE": "Note",
|
||||
"LATEST_CONVERSATION": "Message",
|
||||
"NO_NOTES": "No notes available",
|
||||
"NO_CONVERSATIONS": "No previous conversations"
|
||||
},
|
||||
"SOCIAL_MEDIA": {
|
||||
"TITLE": "Edit social links",
|
||||
"FORM": {
|
||||
@@ -608,7 +627,7 @@
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"CLEAR_SELECTION": "Clear",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"DELETE_CONTACTS": "Delete",
|
||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
||||
|
||||
+41
-45
@@ -80,53 +80,49 @@ const handleAssignLabels = labels => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent mx-6 3xl:mx-0 pt-1"
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
animation-direction="vertical"
|
||||
class="justify-between absolute bottom-20 left-1/2 -translate-x-1/2 z-30 lg:!w-[39rem] sm:!w-[calc(100%-6rem)] !w-[calc(100%-4rem)] max-w-4xl"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
animation-direction="vertical"
|
||||
class="justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1"
|
||||
@click="emitClearSelection"
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1"
|
||||
@click="emitClearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
ghost
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
|
||||
@assign="handleAssignLabels"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
ghost
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</template>
|
||||
|
||||
@@ -404,12 +404,14 @@ onMounted(async () => {
|
||||
pageNumber.value
|
||||
);
|
||||
}
|
||||
// Fetch agents list to show assignee previous conversation in contact card (expanded view)
|
||||
await store.dispatch('agents/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1 relative"
|
||||
>
|
||||
<ContactsListLayout
|
||||
:search-value="searchValue"
|
||||
@@ -463,7 +465,7 @@ onMounted(async () => {
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<div v-else class="flex flex-col gap-4 px-4 pt-4 pb-6">
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
|
||||
@@ -8,7 +8,10 @@ export const getters = {
|
||||
const contacts = $state.sortOrder.map(
|
||||
contactId => $state.records[contactId]
|
||||
);
|
||||
return camelcaseKeys(contacts, { deep: true });
|
||||
return camelcaseKeys(contacts, {
|
||||
deep: true,
|
||||
stopPaths: ['custom_attributes'],
|
||||
});
|
||||
},
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
|
||||
Reference in New Issue
Block a user