feat: Update contact list page (#13020)
This commit is contained in:
+2
-2
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
@@ -103,7 +103,7 @@ onMounted(() => {
|
||||
:key="tag.id"
|
||||
:label="tag"
|
||||
:is-hovered="hoveredLabel === tag.id"
|
||||
class="h-8"
|
||||
class="h-8 ltr:!pr-1 rtl:!pl-1"
|
||||
@remove="onClickRemoveTag"
|
||||
@hover="hoveredLabel = tag.id"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedCompanyName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['change']);
|
||||
const companyId = defineModel({
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const [showCompanyDropdown, toggleCompanyDropdown] = useToggle();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const isSearching = ref(false);
|
||||
|
||||
const companies = computed(() => companiesStore.getCompaniesList || []);
|
||||
const isLoading = computed(() => companiesStore.uiFlags.fetchingList);
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(company => ({
|
||||
label: company.name,
|
||||
value: company.id,
|
||||
action: 'select',
|
||||
isSelected: companyId.value === company.id,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedCompany = computed(() => {
|
||||
return companies.value.find(company => company.id === companyId.value);
|
||||
});
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (selectedCompany.value) {
|
||||
return selectedCompany.value.name;
|
||||
}
|
||||
if (props.selectedCompanyName) {
|
||||
return props.selectedCompanyName;
|
||||
}
|
||||
return props.placeholder;
|
||||
});
|
||||
|
||||
const fetchCompanies = async (search = '') => {
|
||||
if (!search) return;
|
||||
isSearching.value = true;
|
||||
try {
|
||||
await companiesStore.search({
|
||||
search,
|
||||
page: 1,
|
||||
sort: 'name',
|
||||
});
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(query => {
|
||||
searchQuery.value = query;
|
||||
fetchCompanies(query);
|
||||
}, 300);
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
if (companyId.value === value) {
|
||||
companyId.value = null;
|
||||
emit('change', null);
|
||||
} else {
|
||||
companyId.value = value;
|
||||
const company = companies.value.find(c => c.id === value);
|
||||
emit('change', company);
|
||||
}
|
||||
toggleCompanyDropdown(false);
|
||||
};
|
||||
|
||||
const handleSearch = query => {
|
||||
debouncedSearch(query);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
toggleCompanyDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
|
||||
<Button
|
||||
slate
|
||||
sm
|
||||
:label="buttonLabel"
|
||||
no-animation
|
||||
type="button"
|
||||
:variant="showCompanyDropdown ? 'faded' : 'solid'"
|
||||
icon="i-lucide-briefcase-business"
|
||||
justify="start"
|
||||
class="w-full -outline-offset-1"
|
||||
@click="toggleCompanyDropdown()"
|
||||
/>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showCompanyDropdown"
|
||||
:menu-items="companyOptions"
|
||||
show-search
|
||||
disable-local-filtering
|
||||
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
|
||||
:empty-state-message="t('COMPANIES.DROPDOWN_MENU.EMPTY_STATE')"
|
||||
:is-loading="isLoading"
|
||||
:is-searching="isSearching"
|
||||
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full"
|
||||
@action="handleAction"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -87,7 +87,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, watch, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
@@ -14,7 +13,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
|
||||
@@ -24,12 +22,14 @@ const showDropdown = ref(false);
|
||||
const hoveredLabel = ref(null);
|
||||
|
||||
const allLabels = useMapGetter('labels/getLabels');
|
||||
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
const getContactById = useMapGetter('contacts/getContactById');
|
||||
const getContactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
|
||||
const savedLabels = computed(() => {
|
||||
const availableContactLabels = contactLabels.value(props.contactId);
|
||||
const contactLabelsList = getContactLabels.value(props.contactId) || [];
|
||||
|
||||
return allLabels.value.filter(({ title }) =>
|
||||
availableContactLabels.includes(title)
|
||||
contactLabelsList.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,13 +47,6 @@ const labelMenuItems = computed(() => {
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
|
||||
const fetchLabels = async contactId => {
|
||||
if (!contactId) {
|
||||
return;
|
||||
}
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
@@ -80,6 +73,11 @@ const handleLabelAction = async ({ value }) => {
|
||||
labels: updatedLabels,
|
||||
});
|
||||
|
||||
store.dispatch('contacts/updateContactLabels', {
|
||||
contactId: props.contactId,
|
||||
labels: updatedLabels,
|
||||
});
|
||||
|
||||
showDropdown.value = false;
|
||||
} catch (error) {
|
||||
// error
|
||||
@@ -90,19 +88,19 @@ const handleRemoveLabel = label => {
|
||||
return handleLabelAction({ value: label.id });
|
||||
};
|
||||
|
||||
// Sync contact labels from contact object to contactLabels store when contact loads
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
fetchLabels(newVal);
|
||||
() => getContactById.value(props.contactId),
|
||||
contact => {
|
||||
if (contact?.labels) {
|
||||
store.dispatch('contactLabels/setContactLabel', {
|
||||
id: props.contactId,
|
||||
data: contact.labels,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
onMounted(() => {
|
||||
if (route.params.contactId) {
|
||||
fetchLabels(route.params.contactId);
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Reset hover state when mouse leaves the container
|
||||
@@ -119,12 +117,16 @@ const handleLabelHover = labelId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 ltr:mr-10 rtl:ml-10"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<LabelItem
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
class="ltr:!pr-1 rtl:!pl-1"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const state = reactive({
|
||||
message: '',
|
||||
});
|
||||
|
||||
const onAdd = async content => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await store.dispatch('contactNotes/create', {
|
||||
content,
|
||||
contactId: props.contactId,
|
||||
});
|
||||
state.message = '';
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Enter': {
|
||||
action: () => onAdd(state.message),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-4 py-3">
|
||||
<span
|
||||
class="py-1 text-sm font-medium text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-file-plus-corner"
|
||||
class="size-4 text-n-slate-11 hidden lg:block"
|
||||
/>
|
||||
{{ t('CONTACTS_LAYOUT.CARD.ADD_NOTE.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-6 lg:px-6 max-w-lg w-full">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.CARD.ADD_NOTE.PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:!px-3 [&>div]:!pb-4 [&_.ProseMirror-woot-style]:min-h-6 [&_.ProseMirror-menubar]:!relative ltr:[&_.ProseMirror-menubar]:!-left-[3px] ltr:[&_.ProseMirror-menubar]:!right-[unset] rtl:[&_.ProseMirror-menubar]:!-right-[3px] rtl:[&_.ProseMirror-menubar]:!left-[unset] [&_.ProseMirror-menubar]:!w-[unset] [&_.ProseMirror-menubar]:!top-[unset] [&_.ProseMirror-menubar-spacer]:!hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } 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 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';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-woot-linkedin',
|
||||
FACEBOOK: 'i-woot-facebook',
|
||||
INSTAGRAM: 'i-woot-instagram',
|
||||
TWITTER: 'i-woot-x',
|
||||
GITHUB: 'i-woot-github',
|
||||
};
|
||||
|
||||
const formState = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
city: '',
|
||||
countryCode: '',
|
||||
bio: '',
|
||||
companyName: '',
|
||||
companyId: null,
|
||||
socialProfiles: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
firstName: { required },
|
||||
email: { email: emailValidator },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, formState);
|
||||
|
||||
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
</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-sm font-medium 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"
|
||||
/>
|
||||
{{ 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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div v-if="contactData?.id" class="lg:px-6">
|
||||
<ContactLabels :contact-id="contactData.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4 py-2">
|
||||
<span
|
||||
class="py-1 text-sm font-medium 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"
|
||||
/>
|
||||
{{ 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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showDeleteSection, toggleDeleteSection] = useToggle();
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
|
||||
const openConfirmDeleteContactDialog = () => {
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
@@ -2,19 +2,21 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ContactCardForm from 'dashboard/components-next/Contacts/ContactsCard/ContactCardForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
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 countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
labels: { type: Array, default: () => [] },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
@@ -31,11 +33,13 @@ const emit = defineEmits([
|
||||
'showContact',
|
||||
'select',
|
||||
'avatarHover',
|
||||
'sendMessage',
|
||||
'deleteContact',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactsFormRef = ref(null);
|
||||
const contactCardFormRef = ref(null);
|
||||
|
||||
const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
@@ -47,7 +51,7 @@ const getInitialContactData = () => ({
|
||||
|
||||
const contactData = ref(getInitialContactData());
|
||||
|
||||
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
const isFormInvalid = computed(() => contactCardFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
@@ -75,6 +79,11 @@ const countryDetails = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const companyName = computed(() => {
|
||||
const attributes = props.additionalAttributes || {};
|
||||
return attributes.companyName || '';
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
|
||||
@@ -85,10 +94,11 @@ const formattedLocation = computed(() => {
|
||||
|
||||
const handleFormUpdate = updatedData => {
|
||||
Object.assign(contactData.value, updatedData);
|
||||
emit('updateContact', contactData.value);
|
||||
};
|
||||
|
||||
const handleUpdateContact = () => {
|
||||
emit('updateContact', contactData.value);
|
||||
contactCardFormRef.value?.handleUpdate();
|
||||
};
|
||||
|
||||
const onClickExpand = () => {
|
||||
@@ -98,6 +108,12 @@ const onClickExpand = () => {
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
|
||||
const onClickOpenSendMessage = () => emit('sendMessage', props.id);
|
||||
|
||||
const onClickDeleteContact = () => {
|
||||
emit('deleteContact', props.id);
|
||||
};
|
||||
|
||||
const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
@@ -109,30 +125,44 @@ const handleAvatarHover = isHovered => {
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<CardLayout
|
||||
:key="id"
|
||||
layout="row"
|
||||
:class="{
|
||||
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||
}"
|
||||
<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="{ 'border-b border-n-weak lg:border-none': isExpanded }"
|
||||
>
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div
|
||||
class="flex items-center gap-3 lg:gap-2"
|
||||
:class="{ 'lg:col-span-3': isExpanded }"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
class="relative hidden lg:block size-5 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
link
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
class="flex-shrink-0 !size-8 absolute -inset-1.5"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-shrink-0 flex items-center"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:size="24"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||
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
|
||||
>
|
||||
@@ -144,101 +174,215 @@ const handleAvatarHover = isHovered => {
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4
|
||||
class="text-sm my-0 capitalize hover:cursor-pointer truncate text-n-slate-12 font-medium lg:max-w-40"
|
||||
@click="onClickViewDetails"
|
||||
>
|
||||
{{ name }}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="hidden lg:flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.SEND_MESSAGE')"
|
||||
icon="i-lucide-message-circle"
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline"
|
||||
@click="onClickOpenSendMessage"
|
||||
/>
|
||||
<div
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.VIEW_DETAILS')"
|
||||
icon="i-lucide-info"
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
<Policy
|
||||
:permissions="['administrator']"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<div
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.DELETE_CONTACT')"
|
||||
icon="i-lucide-trash-2"
|
||||
link
|
||||
sm
|
||||
ruby
|
||||
class="hover:!no-underline"
|
||||
@click="onClickDeleteContact"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="companyName"
|
||||
class="text-xs my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 font-440 max-w-40 min-w-0 outline outline-1 outline-n-weak"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-briefcase-business"
|
||||
class="size-3.5 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="truncate">{{ 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 text-sm 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">{{ 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"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
link
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
class="flex-shrink-0 !size-8 absolute -inset-1.5"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 lg:gap-2 lg:min-w-0"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<div
|
||||
v-if="email"
|
||||
v-tooltip.top="{
|
||||
content: email,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate lg:max-w-72"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-mail"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-420 truncate">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="email && phoneNumber"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="phoneNumber"
|
||||
v-tooltip.top="{
|
||||
content: phoneNumber,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate lg:max-w-72"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-phone"
|
||||
class="size-3 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-420 truncate">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:hidden">
|
||||
<div v-if="labels.length > 0" class="min-h-[1rem]">
|
||||
<ContactLabels :labels="labels" disable-toggle class="my-0" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
link
|
||||
xs
|
||||
class="w-fit hover:!no-underline"
|
||||
slate
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden lg:flex items-center justify-end gap-3 flex-shrink-0"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<ContactLabels
|
||||
:labels="labels"
|
||||
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>
|
||||
|
||||
<div
|
||||
class="grid [transition:grid-template-rows_300ms_ease-out]"
|
||||
:class="[
|
||||
isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
isExpanded ? '' : 'overflow-hidden',
|
||||
]"
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits([
|
||||
<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-[60rem]"
|
||||
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"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
|
||||
+1
-1
@@ -294,7 +294,7 @@ defineExpose({
|
||||
>
|
||||
<template #filter>
|
||||
<div
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
class="absolute z-20 mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
:show-clear-button="!hasActiveSegments"
|
||||
class="max-w-[60rem] px-6"
|
||||
class="max-w-[105rem] px-6 relative z-20"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
|
||||
@@ -86,7 +86,7 @@ const openFilter = () => {
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<div class="w-full mx-auto max-w-[105rem]">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
@@ -96,7 +96,7 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
@@ -29,6 +31,10 @@ 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);
|
||||
const contactToDelete = ref(null);
|
||||
|
||||
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||
|
||||
@@ -66,8 +72,18 @@ const onClickViewDetails = async id => {
|
||||
await router.push({ name, params, query: route.query });
|
||||
};
|
||||
|
||||
const toggleExpanded = id => {
|
||||
const toggleExpanded = async id => {
|
||||
const isExpanding = expandedCardId.value !== id;
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
|
||||
// Fetch contactable inboxes when expanding a card
|
||||
if (isExpanding) {
|
||||
try {
|
||||
await store.dispatch('contacts/fetchContactableInbox', id);
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
@@ -83,10 +99,23 @@ const handleSelect = (id, value) => {
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleSendMessage = id => {
|
||||
selectedContactId.value = String(id);
|
||||
composeConversationRef.value?.toggle();
|
||||
};
|
||||
|
||||
const handleDeleteContact = id => {
|
||||
const contact = props.contacts.find(c => c.id === id);
|
||||
if (contact) {
|
||||
contactToDelete.value = { id: contact.id, name: contact.name };
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col divide-y divide-n-slate-3">
|
||||
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||
<ContactsCard
|
||||
:id="contact.id"
|
||||
@@ -100,12 +129,33 @@ const handleAvatarHover = (id, isHovered) => {
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
<ComposeConversation
|
||||
ref="composeConversationRef"
|
||||
:contact-id="selectedContactId"
|
||||
is-modal
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden"
|
||||
aria-hidden="true"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="contactToDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+47
-23
@@ -1,13 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, computed, inject, nextTick, useSlots, watch } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
useSlots,
|
||||
watch,
|
||||
useAttrs,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
@@ -17,6 +26,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -24,7 +36,7 @@ const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.conversationLabels.includes(title)
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,8 +103,31 @@ const hiddenLabelsCount = computed(() => {
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return `+${hiddenLabelsCount.value}`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle || !showExpandLabelButton.value) return '';
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
@@ -118,6 +153,7 @@ const onShowLabels = e => {
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
@@ -125,39 +161,27 @@ const onShowLabels = e => {
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<div
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:title="label.description"
|
||||
class="bg-n-button-color px-1.5 h-6 gap-1 rounded-md -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="rounded-sm size-1.5 flex-shrink-0"
|
||||
:style="{ background: label.color }"
|
||||
/>
|
||||
<span class="font-440 text-xs text-n-slate-12 whitespace-nowrap">
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton"
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
}"
|
||||
:label="
|
||||
!showAllLabels && hiddenLabelsCount > 0 ? `+${hiddenLabelsCount}` : ''
|
||||
"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="
|
||||
!showAllLabels && hiddenLabelsCount > 0 ? '' : 'i-lucide-chevron-left'
|
||||
"
|
||||
:icon="labelsCountText ? '' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ const onSelectConversation = checked => {
|
||||
|
||||
<CardLabels
|
||||
v-if="showLabelsSection || isInboxView"
|
||||
:conversation-labels="chat.labels"
|
||||
:labels="chat.labels"
|
||||
>
|
||||
<template v-if="isInboxView" #before>
|
||||
<CardMetaSection
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ const selectedModel = computed({
|
||||
<div class="flex items-center justify-end gap-2 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:conversation-labels="chat.labels"
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['change']);
|
||||
const countryCode = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showCountryDropdown, toggleCountryDropdown] = useToggle();
|
||||
const searchQuery = ref('');
|
||||
|
||||
const countryOptions = computed(() =>
|
||||
countries.map(({ name, id }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
action: 'select',
|
||||
isSelected: countryCode.value === id,
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredCountries = computed(() => {
|
||||
if (!searchQuery.value) return countryOptions.value;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return countryOptions.value.filter(country =>
|
||||
country.label.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedCountry = computed(() => {
|
||||
return countries.find(country => country.id === countryCode.value);
|
||||
});
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (selectedCountry.value) {
|
||||
return selectedCountry.value.name;
|
||||
}
|
||||
return props.placeholder;
|
||||
});
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
if (countryCode.value === value) {
|
||||
countryCode.value = '';
|
||||
emit('change', null);
|
||||
} else {
|
||||
countryCode.value = value;
|
||||
const country = countries.find(c => c.id === value);
|
||||
emit('change', country);
|
||||
}
|
||||
toggleCountryDropdown(false);
|
||||
};
|
||||
|
||||
const handleSearch = query => {
|
||||
searchQuery.value = query;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
toggleCountryDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
|
||||
<Button
|
||||
slate
|
||||
sm
|
||||
:label="buttonLabel"
|
||||
:icon="!selectedCountry ? 'i-lucide-flag' : ''"
|
||||
:variant="showCountryDropdown ? 'faded' : 'solid'"
|
||||
type="button"
|
||||
no-animation
|
||||
justify="start"
|
||||
class="w-full -outline-offset-1"
|
||||
@click="toggleCountryDropdown()"
|
||||
>
|
||||
<template v-if="selectedCountry" #icon>
|
||||
<Flag :country="selectedCountry.id" class="size-3" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showCountryDropdown"
|
||||
:menu-items="filteredCountries"
|
||||
show-search
|
||||
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
|
||||
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full min-w-40"
|
||||
@action="handleAction"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #icon="{ item }">
|
||||
<Flag :country="item.value" class="size-3" />
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
defineProps({
|
||||
labelMenuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateLabel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-6 outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
|
||||
:class="{ 'bg-n-alpha-2': showDropdown }"
|
||||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<span class="i-lucide-plus" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('LABEL.TAG_BUTTON') }}
|
||||
</span>
|
||||
</button>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
v-on-clickaway="() => (showDropdown = false)"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
@action="emit('updateLabel', $event)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<div
|
||||
class="rounded-sm size-2"
|
||||
:style="{ backgroundColor: item.thumbnail.color }"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isHovered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove', 'hover']);
|
||||
|
||||
const handleRemoveLabel = () => {
|
||||
emit('remove', props.label);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// Notify parent component when this label is hovered
|
||||
// Added this to show the remove button with transition when hovering over the label
|
||||
// This will solve the flickering issue when hovering over the last label item
|
||||
emit('hover', props.label?.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center px-1 py-1 overflow-hidden transition-all duration-300 ease-out rounded-md bg-n-alpha-2 h-7"
|
||||
@mouseenter="handleMouseEnter"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 m-1 rounded-sm"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 ltr:mr-px rtl:ml-px">
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<div
|
||||
class="w-0 flex relative ltr:left-1 rtl:right-1 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
|
||||
:class="{ 'w-6': isHovered }"
|
||||
>
|
||||
<Button
|
||||
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
|
||||
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
|
||||
type="button"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
icon="i-lucide-x"
|
||||
@click="handleRemoveLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -232,6 +232,10 @@ const keyboardEvents = {
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -52,16 +52,16 @@ const toggleSidebar = () => {
|
||||
<template>
|
||||
<div
|
||||
v-if="showCopilotLauncher"
|
||||
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
||||
class="fixed bottom-16 md:bottom-[4.5rem] ltr:right-0 rtl:left-0 md:ltr:right-[0.813rem] md:rtl:left-[0.813rem] z-50"
|
||||
>
|
||||
<ButtonGroup
|
||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
class="ltr:rounded-l-full ltr:rounded-r-none rtl:rounded-r-full rtl:rounded-l-none bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
>
|
||||
<Button
|
||||
icon="i-woot-captain"
|
||||
no-animation
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||
lg
|
||||
md
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -56,6 +56,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
default: 'DROPDOWN_MENU.EMPTY_STATE',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'search', 'empty']);
|
||||
@@ -289,7 +293,9 @@ onMounted(() => {
|
||||
{{
|
||||
isSearching
|
||||
? t('DROPDOWN_MENU.SEARCHING')
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: searchQuery
|
||||
? t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
: t(emptyStateMessage)
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
|
||||
@@ -137,7 +137,7 @@ onMounted(() => {
|
||||
? max
|
||||
: undefined
|
||||
"
|
||||
class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 -outline-offset-1 rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
defineProps({
|
||||
labelMenuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateLabel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const { positionClasses } = useDropdownPosition(
|
||||
triggerRef,
|
||||
dropdownRef,
|
||||
showDropdown
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
ref="triggerRef"
|
||||
:label="t('LABEL.TAG_BUTTON')"
|
||||
sm
|
||||
slate
|
||||
:variant="showDropdown ? 'faded' : 'solid'"
|
||||
class="font-460 !-outline-offset-1"
|
||||
icon="i-lucide-plus"
|
||||
@click="showDropdown = !showDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
ref="dropdownRef"
|
||||
v-on-clickaway="() => (showDropdown = false)"
|
||||
:menu-items="labelMenuItems"
|
||||
show-search
|
||||
class="z-[100] w-48 overflow-y-auto max-h-52"
|
||||
:class="positionClasses"
|
||||
@action="emit('updateLabel', $event)"
|
||||
>
|
||||
<template #thumbnail="{ item }">
|
||||
<div
|
||||
class="rounded-sm size-2"
|
||||
:style="{ backgroundColor: item.thumbnail.color }"
|
||||
/>
|
||||
</template>
|
||||
<template #trailing-icon="{ item }">
|
||||
<Icon
|
||||
v-if="item.isSelected"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 text-n-blue-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:title="label.description"
|
||||
class="bg-n-button-color gap-1.5 rounded-lg -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
|
||||
:class="compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 rounded-lg'"
|
||||
>
|
||||
<span
|
||||
class="rounded-sm flex-shrink-0"
|
||||
:class="compact ? 'size-1.5' : 'size-2'"
|
||||
:style="{ background: label.color }"
|
||||
/>
|
||||
<span
|
||||
class="text-n-slate-12 whitespace-nowrap"
|
||||
:class="compact ? 'font-440 text-xs' : 'font-420 text-sm'"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isHovered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove', 'hover']);
|
||||
|
||||
const handleRemoveLabel = () => {
|
||||
emit('remove', props.label);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// Notify parent component when this label is hovered
|
||||
// Added this to show the remove button with transition when hovering over the label
|
||||
// This will solve the flickering issue when hovering over the last label item
|
||||
emit('hover', props.label?.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label :label="label" :compact="compact" @mouseenter="handleMouseEnter">
|
||||
<template #action>
|
||||
<div
|
||||
class="w-0 flex relative ltr:left-0.5 rtl:right-0.5 flex-shrink-0 overflow-hidden transition-[width] duration-300 ease-out"
|
||||
:class="{ 'w-6': isHovered }"
|
||||
>
|
||||
<Button
|
||||
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
|
||||
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
|
||||
type="button"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
icon="i-lucide-x"
|
||||
@click="handleRemoveLabel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Label>
|
||||
</template>
|
||||
@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
class="flex justify-between h-[3.375rem] w-full max-w-[105rem] border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||
<span class="min-w-0 text-sm font-420 line-clamp-1 text-n-slate-11">
|
||||
{{ currentPageInformation }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -97,11 +97,13 @@ const pageInfo = computed(() => {
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(currentPage - 1)"
|
||||
/>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
<div class="inline-flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-n-slate-12 rounded-md"
|
||||
>
|
||||
{{ formatFullNumber(currentPage) }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
<span class="truncate font-420 text-n-slate-11">
|
||||
{{ pageInfo }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -185,6 +185,7 @@ watch(
|
||||
"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
no-animation
|
||||
type="button"
|
||||
class="!h-[1.875rem] top-1 ltr:ml-px rtl:mr-px !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="toggleCountryDropdown"
|
||||
|
||||
@@ -39,21 +39,26 @@ const toggleSidebar = () => {
|
||||
<div
|
||||
v-if="!isConversationRoute"
|
||||
id="mobile-sidebar-launcher"
|
||||
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
|
||||
class="fixed bottom-16 md:bottom-[4.5rem] ltr:left-0 rtl:right-0 z-40 transition-transform duration-200 ease-in-out block md:hidden"
|
||||
:class="[
|
||||
{
|
||||
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
||||
'ltr:translate-x-[12.5rem] rtl:-translate-x-[12.5rem]':
|
||||
isMobileSidebarOpen,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ButtonGroup
|
||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
class="ltr:rounded-r-full ltr:rounded-l-none rtl:rounded-l-full rtl:rounded-r-none bg-n-alpha-2 backdrop-blur-lg p-1 hover:shadow-md"
|
||||
:class="{
|
||||
'shadow-none': isMobileSidebarOpen,
|
||||
shadow: !isMobileSidebarOpen,
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-menu"
|
||||
no-animation
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||
lg
|
||||
md
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -123,7 +123,7 @@ const allowedMenuItems = computed(() => {
|
||||
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
class="flex gap-2 items-center p-1 w-full h-12 text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
},
|
||||
"DROPDOWN_MENU": {
|
||||
"EMPTY_STATE": "Start by searching to see results"
|
||||
}
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"TOGGLE": "Toggle switch"
|
||||
},
|
||||
"LABEL": {
|
||||
"TAG_BUTTON": "tag"
|
||||
"TAG_BUTTON": "Add label"
|
||||
},
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"LEARN_MORE": "Learn more",
|
||||
|
||||
@@ -419,6 +419,11 @@
|
||||
"CARD": {
|
||||
"OF": "of",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"ACTIONS": {
|
||||
"SEND_MESSAGE": "Send message",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"DELETE_CONTACT": "Delete contact"
|
||||
},
|
||||
"EDIT_DETAILS_FORM": {
|
||||
"TITLE": "Edit contact details",
|
||||
"FORM": {
|
||||
@@ -437,7 +442,7 @@
|
||||
"DUPLICATE": "This phone number is in use for another contact."
|
||||
},
|
||||
"CITY": {
|
||||
"PLACEHOLDER": "Enter the city name"
|
||||
"PLACEHOLDER": "Enter city"
|
||||
},
|
||||
"COUNTRY": {
|
||||
"PLACEHOLDER": "Select country"
|
||||
@@ -446,13 +451,24 @@
|
||||
"PLACEHOLDER": "Enter the bio"
|
||||
},
|
||||
"COMPANY_NAME": {
|
||||
"PLACEHOLDER": "Enter the company name"
|
||||
"PLACEHOLDER": "Select Company"
|
||||
}
|
||||
},
|
||||
"UPDATE_BUTTON": "Update contact",
|
||||
"SUCCESS_MESSAGE": "Contact updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update contact. Please try again later."
|
||||
},
|
||||
"ADD_NOTE": {
|
||||
"TITLE": "Add a quick note",
|
||||
"PLACEHOLDER": "Type your note here and press cmd+enter to save",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact note added successfully",
|
||||
"ERROR_MESSAGE": "Unable to add contact note. Please try again later."
|
||||
}
|
||||
},
|
||||
"LABELS": {
|
||||
"ADD_BUTTON": "Add Labels"
|
||||
},
|
||||
"SOCIAL_MEDIA": {
|
||||
"TITLE": "Edit social links",
|
||||
"FORM": {
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
|
||||
+6
-5
@@ -81,20 +81,19 @@ const handleAssignLabels = labels => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent px-6 pt-1 pb-2"
|
||||
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"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
class="py-2 ltr:!pr-2 rtl:!pl-2 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1"
|
||||
@click="emitClearSelection"
|
||||
@@ -106,20 +105,22 @@ const handleAssignLabels = labels => {
|
||||
type="contact"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
class="[&>button]:!text-n-blue-11 [&>button]:!px-2"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
<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
|
||||
faded
|
||||
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"
|
||||
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
|
||||
@@ -13,6 +13,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -125,21 +126,12 @@ const hideCreateModal = () => {
|
||||
class="text-n-slate-10"
|
||||
/>
|
||||
<div v-else class="flex flex-wrap gap-2.5">
|
||||
<div
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:title="label.description"
|
||||
class="bg-n-button-color px-2.5 h-8 gap-1.5 rounded-lg -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
|
||||
>
|
||||
<span
|
||||
class="rounded-sm size-2 flex-shrink-0"
|
||||
:style="{ background: label.color }"
|
||||
/>
|
||||
<span class="font-420 text-sm text-n-slate-12 whitespace-nowrap">
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
:label="label"
|
||||
/>
|
||||
<div
|
||||
v-on-click-outside="() => toggleLabels(false)"
|
||||
class="relative w-fit"
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ const routerParams = computed(() => ({
|
||||
v-if="conversationLabels.length"
|
||||
class="w-[60%]"
|
||||
:conversation-id="conversationId"
|
||||
:conversation-labels="conversationLabels"
|
||||
:labels="conversationLabels"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -320,4 +320,11 @@ export const actions = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateContactLabels({ commit }, { contactId, labels }) {
|
||||
commit(types.SET_CONTACT_ITEM, {
|
||||
id: contactId,
|
||||
labels,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user