fix(shangwutong): 完善访客资料同步

This commit is contained in:
Rogee
2026-08-06 15:14:32 +08:00
parent 3bceb2d0b2
commit 5c47d1d5fe
24 changed files with 1800 additions and 952 deletions
@@ -1,8 +1,8 @@
<script>
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
@@ -14,424 +14,472 @@ import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default {
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
inProgress: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
countries: countries,
companyName: '',
description: '',
email: '',
name: '',
phoneNumber: '',
activeDialCode: '',
avatarFile: null,
avatarUrl: '',
country: {
id: '',
name: '',
},
city: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {
email,
},
companyName: {},
phoneNumber: {},
bio: {},
},
computed: {
parsePhoneNumber() {
return parsePhoneNumber(this.phoneNumber);
},
isPhoneNumberNotValid() {
if (this.phoneNumber !== '') {
return (
!isPhoneNumberValid(this.phoneNumber, this.activeDialCode) ||
(this.phoneNumber !== '' ? this.activeDialCode === '' : false)
);
}
return false;
},
phoneNumberError() {
if (this.activeDialCode === '') {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR');
}
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
}
return '';
},
setPhoneNumber() {
if (this.parsePhoneNumber && this.parsePhoneNumber.countryCallingCode) {
return this.phoneNumber;
}
if (this.phoneNumber === '' && this.activeDialCode !== '') {
return '';
}
return this.activeDialCode
? `${this.activeDialCode}${this.phoneNumber}`
: '';
},
},
watch: {
contact() {
this.setContactObject();
},
},
mounted() {
this.setContactObject();
this.setDialCode();
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
countryNameWithCode({ name, id }) {
if (!id) return name;
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() {
if (
this.phoneNumber !== '' &&
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
this.activeDialCode = `+${dialCode}`;
}
},
setContactObject() {
const {
email: emailAddress,
phone_number: phoneNumber,
name,
} = this.contact;
const additionalAttributes = this.contact.additional_attributes || {};
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
inProgress: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
countries: countries,
companyName: '',
description: '',
email: '',
name: '',
phoneNumber: '',
activeDialCode: '',
avatarFile: null,
avatarUrl: '',
country: {
id: '',
name: '',
},
city: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {
email,
},
companyName: {},
phoneNumber: {},
bio: {},
},
computed: {
parsePhoneNumber() {
return parsePhoneNumber(this.phoneNumber);
},
isPhoneNumberNotValid() {
if (this.phoneNumber !== '') {
return (
!isPhoneNumberValid(
this.phoneNumber,
this.activeDialCode
) ||
(this.phoneNumber !== ''
? this.activeDialCode === ''
: false)
);
}
return false;
},
phoneNumberError() {
if (this.activeDialCode === '') {
return this.$t(
'CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR'
);
}
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
}
return '';
},
setPhoneNumber() {
if (
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
return this.phoneNumber;
}
if (this.phoneNumber === '' && this.activeDialCode !== '') {
return '';
}
return this.activeDialCode
? `${this.activeDialCode}${this.phoneNumber}`
: '';
},
},
watch: {
contact() {
this.setContactObject();
},
},
mounted() {
this.setContactObject();
this.setDialCode();
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
countryNameWithCode({ name, id }) {
if (!id) return name;
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
this.setPhoneCode(selected?.dial_code || '');
},
setDialCode() {
if (
this.phoneNumber !== '' &&
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
this.activeDialCode = `+${dialCode}`;
}
},
setContactObject() {
const {
email: emailAddress,
phone_number: phoneNumber,
name,
} = this.contact;
const additionalAttributes =
this.contact.additional_attributes || {};
this.name = name || '';
this.email = emailAddress || '';
this.phoneNumber = phoneNumber || '';
this.companyName = additionalAttributes.company_name || '';
this.country = {
id: additionalAttributes.country_code || '',
name:
additionalAttributes.country ||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
};
this.city = additionalAttributes.city || '';
this.description = additionalAttributes.description || '';
this.avatarUrl = this.contact.thumbnail || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
},
getContactObject() {
if (this.country === null) {
this.country = {
id: '',
name: '',
};
}
const contactObject = {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.setPhoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
company_name: this.companyName,
country_code: this.country.id,
country:
this.country.name ===
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
? ''
: this.country.name,
city: this.city,
social_profiles: this.socialProfileUserNames,
},
};
if (this.avatarFile) {
contactObject.avatar = this.avatarFile;
contactObject.isFormData = true;
}
return contactObject;
},
setPhoneCode(code) {
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
if (dialCode === code) {
return;
}
this.activeDialCode = `+${dialCode}`;
const newPhoneNumber = this.phoneNumber.replace(
`+${dialCode}`,
`${code}`
);
this.phoneNumber = newPhoneNumber;
} else {
this.activeDialCode = code;
}
},
async handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
return;
}
try {
await this.onSubmit(this.getContactObject());
this.onSuccess();
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (error.data.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
if (this.contact && this.contact.id) {
await this.$store.dispatch('contacts/deleteAvatar', this.contact.id);
useAlert(this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'));
}
this.avatarFile = null;
this.avatarUrl = '';
this.activeDialCode = '';
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE')
);
}
},
},
this.name = name || '';
this.email = emailAddress || '';
this.phoneNumber = phoneNumber || '';
this.companyName = additionalAttributes.company_name || '';
this.country = {
id:
additionalAttributes.country_code ||
this.contact.country_code ||
'',
name:
additionalAttributes.country ||
this.countries.find(
country =>
country.id ===
(additionalAttributes.country_code ||
this.contact.country_code)
)?.name ||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
};
if (!this.phoneNumber && this.country.id) {
this.activeDialCode =
this.countries.find(
country => country.id === this.country.id
)?.dial_code || '';
}
this.city = additionalAttributes.city || '';
this.description = additionalAttributes.description || '';
this.avatarUrl = this.contact.thumbnail || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
},
getContactObject() {
if (this.country === null) {
this.country = {
id: '',
name: '',
};
}
const contactObject = {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.setPhoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
company_name: this.companyName,
country_code: this.country.id,
country:
this.country.name ===
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
? ''
: this.country.name,
city: this.city,
social_profiles: this.socialProfileUserNames,
},
};
if (this.avatarFile) {
contactObject.avatar = this.avatarFile;
contactObject.isFormData = true;
}
return contactObject;
},
setPhoneCode(code) {
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
if (dialCode === code) {
return;
}
this.activeDialCode = `+${dialCode}`;
const newPhoneNumber = this.phoneNumber.replace(
`+${dialCode}`,
`${code}`
);
this.phoneNumber = newPhoneNumber;
} else {
this.activeDialCode = code;
}
},
async handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
return;
}
try {
await this.onSubmit(this.getContactObject());
this.onSuccess();
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(
this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE')
);
} else if (error.data.includes('phone_number')) {
useAlert(
this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE')
);
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
if (this.contact && this.contact.id) {
await this.$store.dispatch(
'contacts/deleteAvatar',
this.contact.id
);
useAlert(
this.$t(
'CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'
)
);
}
this.avatarFile = null;
this.avatarUrl = '';
this.activeDialCode = '';
} catch (error) {
useAlert(
error.message
? error.message
: this.$t(
'CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE'
)
);
}
},
},
};
</script>
<template>
<form
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
<label :class="{ error: v$.name.$error }">
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
</label>
<form
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
<label :class="{ error: v$.name.$error }">
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
</label>
<label :class="{ error: v$.email.$error }">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model="email"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<label :class="{ error: v$.description.$error }">
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
<textarea
v-model="description"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
@input="v$.description.$touch"
/>
</label>
</div>
<div>
<div class="w-full">
<label
:class="{
error: isPhoneNumberNotValid,
}"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<woot-phone-input
v-model="phoneNumber"
:value="phoneNumber"
:error="isPhoneNumberNotValid"
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
@set-code="setPhoneCode"
/>
<span v-if="isPhoneNumberNotValid" class="message">
{{ phoneNumberError }}
</span>
</label>
<div
v-if="isPhoneNumberNotValid || !phoneNumber"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model="companyName"
class="w-full"
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
@update:model-value="onCountryChange"
/>
</div>
<woot-input
v-model="city"
class="w-full"
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
/>
<label :class="{ error: v$.email.$error }">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model="email"
type="text"
:placeholder="
$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')
"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<label :class="{ error: v$.description.$error }">
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
<textarea
v-model="description"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
@input="v$.description.$touch"
/>
</label>
</div>
<div>
<div class="w-full">
<label
:class="{
error: isPhoneNumberNotValid,
}"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<woot-phone-input
v-model="phoneNumber"
:value="phoneNumber"
:error="isPhoneNumberNotValid"
:placeholder="
$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')
"
@blur="v$.phoneNumber.$touch"
@set-code="setPhoneCode"
/>
<span v-if="isPhoneNumberNotValid" class="message">
{{ phoneNumberError }}
</span>
</label>
<div
v-if="isPhoneNumberNotValid || !phoneNumber"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model="companyName"
class="w-full"
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="
$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')
"
@update:model-value="onCountryChange"
/>
</div>
<woot-input
v-model="city"
class="w-full"
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
/>
<div class="w-full">
<label>{{ $t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES') }}</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="flex items-stretch w-full mb-4"
>
<span
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
type="text"
/>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<NextButton
type="submit"
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
:is-loading="inProgress"
/>
<NextButton
faded
slate
type="reset"
:label="$t('CONTACT_FORM.FORM.CANCEL')"
@click.prevent="onCancel"
/>
</div>
</form>
<div class="w-full">
<label>{{
$t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES')
}}</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="flex items-stretch w-full mb-4"
>
<span
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
type="text"
/>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<NextButton
type="submit"
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
:is-loading="inProgress"
/>
<NextButton
faded
slate
type="reset"
:label="$t('CONTACT_FORM.FORM.CANCEL')"
@click.prevent="onCancel"
/>
</div>
</form>
</template>
@@ -2,8 +2,8 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
@@ -19,341 +19,403 @@ import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
quickPhonePrefix() {
const countryCode = (
this.additionalAttributes.country_code ||
this.contact.country_code ||
''
).toUpperCase();
const country = this.additionalAttributes.country || '';
return countryCode === 'CN' || country === 'China' ? '+86' : '';
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country]
.filter(item => !!item)
.join(', ');
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
return {
...(socialProfiles || {}),
twitter,
telegram,
};
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
return {
...(socialProfiles || {}),
twitter,
telegram,
};
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
this.updateContactField({ [field]: value });
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch('contacts/fetchContactableInbox', contactId);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data) ? error.data : [];
if (invalidAttrs.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (invalidAttrs.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
},
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
const normalizedValue =
field === 'phone_number'
? this.normalizeQuickPhone(value)
: value;
this.updateContactField({ [field]: normalizedValue });
},
normalizeQuickPhone(value) {
const phone = value.trim().replace(/[\s()-]/g, '');
if (!phone || phone.startsWith('+')) return phone;
const countryCode = (
this.additionalAttributes.country_code ||
this.contact.country_code ||
''
).toUpperCase();
const country = this.additionalAttributes.country || '';
if (countryCode === 'CN' || country === 'China') {
return `+86${phone.replace(/^0+/, '')}`;
}
return phone;
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch(
'contacts/fetchContactableInbox',
contactId
);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data)
? error.data
: [];
if (invalidAttrs.includes('email')) {
useAlert(
this.$t(
'CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'
)
);
} else if (invalidAttrs.includes('phone_number')) {
useAlert(
this.$t(
'CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'
)
);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(
error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE')
);
}
}
},
},
};
</script>
<template>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
>
<span class="i-lucide-external-link text-sm text-n-slate-10" />
</a>
</div>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div
v-if="showAvatar"
class="flex items-center w-full min-w-0 gap-3"
>
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
>
<span
class="i-lucide-external-link text-sm text-n-slate-10"
/>
</a>
</div>
</div>
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
slate
faded
sm
@click="toggleEditModal"
/>
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
</div>
</div>
<p
v-if="additionalAttributes.description"
class="break-words mb-0.5"
>
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="
contact.phone_number
? `tel:${contact.phone_number}`
: ''
"
:value="contact.phone_number"
:edit-prefix="quickPhonePrefix"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
slate
faded
sm
@click="toggleEditModal"
/>
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="
$t('CONTACT_PANEL.MERGE_CONTACT')
"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="
$t('DELETE_CONTACT.BUTTON_LABEL')
"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
</div>
</div>
</template>
@@ -6,165 +6,169 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || '';
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
editPrefix: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || this.editPrefix;
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
};
</script>
<template>
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<!-- Read mode with link -->
<a
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode with link -->
<a
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
</template>