feat: Contact card with expand view
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const author = computed(() => ({
|
||||
name: props.name,
|
||||
thumbnail: props.thumbnail,
|
||||
}));
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
await store.dispatch('contacts/update', updatedData);
|
||||
await store.dispatch('contacts/fetchContactableInbox', props.id);
|
||||
};
|
||||
|
||||
const handleFormUpdate = debounce(
|
||||
updatedData => updateContact(updatedData),
|
||||
400,
|
||||
false
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Thumbnail :author="author" :name="name" :src="thumbnail" :size="48" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">{{
|
||||
name
|
||||
}}</span>
|
||||
<template v-if="additionalAttributes?.company_name">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_LAYOUT.CARD.OF')
|
||||
}}</span>
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ additionalAttributes.company_name }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="email" class="text-sm text-n-slate-11">{{
|
||||
email
|
||||
}}</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm text-n-slate-11">{{
|
||||
phoneNumber
|
||||
}}</span>
|
||||
<div v-if="phoneNumber || email" class="w-px h-3 bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="emit('toggle')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #expanded>
|
||||
<transition
|
||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-[360px]"
|
||||
leave-from-class="opacity-100 max-h-[360px]"
|
||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
class="w-full overflow-visible transition-all duration-200"
|
||||
>
|
||||
<div class="border-t border-n-strong">
|
||||
<ContactsForm
|
||||
:contact-data="{
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
phoneNumber,
|
||||
additionalAttributes,
|
||||
}"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/Combobox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const FORM_CONFIG = {
|
||||
FIRST_NAME: { field: 'name' },
|
||||
LAST_NAME: { field: 'name' },
|
||||
EMAIL_ADDRESS: { field: 'email' },
|
||||
PHONE_NUMBER: { field: 'phone_number' },
|
||||
CITY: { field: 'additional_attributes.city' },
|
||||
COUNTRY: { field: 'additional_attributes.country' },
|
||||
BIO: { field: 'additional_attributes.description' },
|
||||
COMPANY_NAME: { field: 'additional_attributes.company_name' },
|
||||
};
|
||||
|
||||
const SOCIAL_CONFIG = {
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
};
|
||||
|
||||
const defaultState = {
|
||||
id: 0,
|
||||
name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
additional_attributes: {
|
||||
description: '',
|
||||
company_name: '',
|
||||
country_code: '',
|
||||
country: '',
|
||||
city: '',
|
||||
social_profiles: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = reactive({ ...defaultState });
|
||||
|
||||
const updateState = () => {
|
||||
Object.assign(state, {
|
||||
id: props.contactData.id,
|
||||
name: props.contactData.name,
|
||||
email: props.contactData.email,
|
||||
phone_number: props.contactData.phoneNumber,
|
||||
additional_attributes: {
|
||||
...state.additional_attributes,
|
||||
...props.contactData.additionalAttributes,
|
||||
social_profiles: {
|
||||
...state.additional_attributes.social_profiles,
|
||||
...props.contactData.additionalAttributes?.social_profiles,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const countryOptions = computed(() =>
|
||||
countries.map(({ name }) => ({ label: name, value: name }))
|
||||
);
|
||||
|
||||
const editDetailsForm = computed(() =>
|
||||
Object.keys(FORM_CONFIG).map(key => ({
|
||||
key,
|
||||
placeholder: t(
|
||||
`CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.${key}.PLACEHOLDER`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
const socialProfilesForm = computed(() =>
|
||||
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
|
||||
key,
|
||||
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
|
||||
icon,
|
||||
}))
|
||||
);
|
||||
|
||||
// Creates a computed property for form field binding based on FORM_CONFIG
|
||||
const getFormBinding = key => {
|
||||
// Get field path from config
|
||||
// Example: FORM_CONFIG.name = { field: 'contact.name' }
|
||||
const field = FORM_CONFIG[key]?.field;
|
||||
if (!field) return null;
|
||||
|
||||
return computed({
|
||||
// Get value from state
|
||||
// Example 1 (nested): field = 'contact.name' returns state.contact.name = "John Doe"
|
||||
// Example 2 (root): field = 'email' returns state.email = "john@example.com"
|
||||
get: () => {
|
||||
const [base, nested] = field.split('.');
|
||||
return nested ? state[base][nested] : state[base];
|
||||
},
|
||||
|
||||
// Set value in state and emit update
|
||||
// Example 1 (nested): field = 'contact.name', value = "Jane Doe" → state.contact.name = "Jane Doe"
|
||||
// Example 2 (root): field = 'email', value = "jane@example.com" → state.email = "jane@example.com"
|
||||
set: value => {
|
||||
const [base, nested] = field.split('.');
|
||||
if (nested) {
|
||||
state[base][nested] = value;
|
||||
} else {
|
||||
state[base] = value;
|
||||
}
|
||||
emit('update', state);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.contactData, updateState, { immediate: true, deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col items-start gap-2 px-6 py-5">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
||||
</span>
|
||||
<div class="grid w-full grid-cols-2 gap-4">
|
||||
<template v-for="item in editDetailsForm" :key="item.key">
|
||||
<ComboBox
|
||||
v-if="item.key === 'COUNTRY'"
|
||||
v-model="state.additional_attributes.country"
|
||||
:options="countryOptions"
|
||||
:placeholder="item.placeholder"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent [&>div>button]:h-8"
|
||||
@update:model-value="emit('update', state)"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="getFormBinding(item.key).value"
|
||||
:placeholder="item.placeholder"
|
||||
custom-input-class="!border-transparent h-8 !py-1"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 px-6 pb-5">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in socialProfilesForm"
|
||||
:key="item.key"
|
||||
class="flex items-center h-8 gap-2 px-2 rounded-lg bg-n-slate-2 dark:bg-n-solid-3"
|
||||
>
|
||||
<Icon
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-4"
|
||||
/>
|
||||
<input
|
||||
v-model="
|
||||
state.additional_attributes.social_profiles[
|
||||
item.key.toLowerCase()
|
||||
]
|
||||
"
|
||||
class="w-auto min-w-[100px] text-sm bg-transparent 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"
|
||||
@input="emit('update', state)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
|
||||
defineProps({
|
||||
contacts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Manage expanded state here
|
||||
const expandedCardId = ref(null);
|
||||
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phone_number"
|
||||
:additional-attributes="contact.additional_attributes"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,7 +80,7 @@ const handleAction = ({ action, value }) => {
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
|
||||
@@ -91,7 +91,7 @@ const handleInput = event => {
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand 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"
|
||||
@input="handleInput"
|
||||
@blur="emit('blur')"
|
||||
/>
|
||||
|
||||
@@ -84,7 +84,7 @@ const pageInfo = computed(() => {
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span>{{ pageInfo }}</span>
|
||||
<span class="truncate">{{ pageInfo }}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-chevron-right"
|
||||
|
||||
@@ -385,5 +385,66 @@
|
||||
"DROPDOWN_ITEM": {
|
||||
"ID": "(ID: {identifier})"
|
||||
}
|
||||
},
|
||||
|
||||
"CONTACTS_LAYOUT": {
|
||||
"HEADER": {
|
||||
"TITLE": "Contacts",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message"
|
||||
},
|
||||
"CARD": {
|
||||
"OF": "of",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"EDIT_DETAILS_FORM": {
|
||||
"TITLE": "Edit contact details",
|
||||
"FORM": {
|
||||
"FIRST_NAME": {
|
||||
"PLACEHOLDER": "Enter the first name"
|
||||
},
|
||||
"LAST_NAME": {
|
||||
"PLACEHOLDER": "Enter the last name"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"PLACEHOLDER": "Enter the email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"PLACEHOLDER": "Enter the phone number"
|
||||
},
|
||||
"CITY": {
|
||||
"PLACEHOLDER": "Enter the city name"
|
||||
},
|
||||
"COUNTRY": {
|
||||
"PLACEHOLDER": "Select country"
|
||||
},
|
||||
"BIO": {
|
||||
"PLACEHOLDER": "Enter the bio"
|
||||
},
|
||||
"COMPANY_NAME": {
|
||||
"PLACEHOLDER": "Enter the company name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SOCIAL_MEDIA": {
|
||||
"TITLE": "Edit social links",
|
||||
"FORM": {
|
||||
"FACEBOOK": {
|
||||
"PLACEHOLDER": "Add Facebook"
|
||||
},
|
||||
"GITHUB": {
|
||||
"PLACEHOLDER": "Add Github"
|
||||
},
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"LINKEDIN": {
|
||||
"PLACEHOLDER": "Add LinkedIn"
|
||||
},
|
||||
"TWITTER": {
|
||||
"PLACEHOLDER": "Add Twitter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user