Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const deleteContact = async id => {
if (!id) return;
try {
await store.dispatch('contacts/delete', id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
emit('goToContactsList');
await deleteContact(route.params.contactId || props.selectedContact.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['export']);
const { t } = useI18n();
const route = useRoute();
const dialogRef = ref(null);
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const uiFlags = useMapGetter('contacts/getUIFlags');
const isExportingContact = computed(() => uiFlags.value.isExporting);
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegment = computed(() =>
activeSegmentId.value
? segments.value.find(view => view.id === Number(activeSegmentId.value))
: undefined
);
const exportContacts = async () => {
let query = { payload: [] };
if (activeSegmentId.value && activeSegment.value) {
query = activeSegment.value.query;
} else if (Object.keys(appliedFilters.value).length > 0) {
query = filterQueryGenerator(appliedFilters.value);
}
emit('export', {
...query,
label: route.params.label || '',
});
};
const handleDialogConfirm = async () => {
await exportContacts();
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
"
:is-loading="isExportingContact"
:disable-confirm-button="isExportingContact"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['import']);
const { t } = useI18n();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isImportingContact = computed(() => uiFlags.value.isImporting);
const dialogRef = ref(null);
const fileInput = ref(null);
const hasSelectedFile = ref(null);
const selectedFileName = ref('');
const csvUrl = '/downloads/import-contacts-sample.csv';
const handleFileClick = () => fileInput.value?.click();
const processFileName = fileName => {
const lastDotIndex = fileName.lastIndexOf('.');
const extension = fileName.slice(lastDotIndex);
const baseName = fileName.slice(0, lastDotIndex);
return baseName.length > 20
? `${baseName.slice(0, 20)}...${extension}`
: fileName;
};
const handleFileChange = () => {
const file = fileInput.value?.files[0];
hasSelectedFile.value = file;
selectedFileName.value = file ? processFileName(file.name) : '';
};
const handleRemoveFile = () => {
hasSelectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = null;
}
selectedFileName.value = '';
};
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
emit('import', hasSelectedFile.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
"
:is-loading="isImportingContact"
:disable-confirm-button="isImportingContact"
@confirm="uploadFile"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
<a
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-11"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
}}
</a>
</p>
</template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-upload"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
color="slate"
variant="ghost"
size="sm"
@click="handleFileClick"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</Dialog>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
type: Object,
required: true,
},
primaryContactId: {
type: [Number, null],
default: null,
},
primaryContactList: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:primaryContactId', 'search']);
const { t } = useI18n();
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
</div>
<ComboBox
id="inbox"
use-api-results
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
isSearching
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
"
:search-placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
:has-error="hasError"
:message="errorMessage"
class="[&>div>button]:bg-n-alpha-black2"
@update:model-value="value => emit('update:primaryContactId', value)"
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
/>
<div
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="selectedContact.name || ''"
:src="selectedContact.thumbnail || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.email }}
</span>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,321 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
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';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
isDetailsView: {
type: Boolean,
default: false,
},
isNewContact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
LAST_NAME: { field: 'lastName' },
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.countryCode' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
const defaultState = {
id: 0,
name: '',
email: '',
firstName: '',
lastName: '',
phoneNumber: '',
additionalAttributes: {
description: '',
companyName: '',
countryCode: '',
country: '',
city: '',
socialProfiles: {
facebook: '',
github: '',
instagram: '',
tiktok: '',
linkedin: '',
twitter: '',
},
},
};
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required },
email: { email },
};
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
}
const {
id,
name = '',
email: emailAddress,
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
const {
description = '',
companyName = '',
countryCode = '',
country = '',
city = '',
socialProfiles = {},
} = additionalAttributes || {};
Object.assign(state, {
id,
name,
firstName,
lastName,
email: emailAddress,
phoneNumber,
additionalAttributes: {
description,
companyName,
countryCode,
country,
city,
socialProfiles,
},
});
};
const countryOptions = computed(() =>
countries.map(({ name, id }) => ({ label: name, value: id }))
);
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,
}))
);
const isValidationField = key => {
const field = FORM_CONFIG[key]?.field;
return ['firstName', 'email'].includes(field);
};
const getValidationKey = key => {
return FORM_CONFIG[key]?.field;
};
// Creates a computed property for two-way form field binding
const getFormBinding = key => {
const field = FORM_CONFIG[key]?.field;
if (!field) return null;
return computed({
get: () => {
// Handle firstName/lastName fields
if (field === 'firstName' || field === 'lastName') {
return state[field]?.toString() || '';
}
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
// Example: 'email' → state.email
// Example: 'additionalAttributes.city' → state.additionalAttributes.city
return (nested ? state[base][nested] : state[base])?.toString() || '';
},
set: async value => {
// Handle name fields specially to maintain the combined 'name' field
if (field === 'firstName' || field === 'lastName') {
state[field] = value;
// Example: firstName="John", lastName="Doe" → name="John Doe"
state.name = `${state.firstName} ${state.lastName}`.trim();
} else {
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
if (nested) {
// Example: additionalAttributes.city = "New York"
state[base][nested] = value;
} else {
// Example: email = "test@example.com"
state[base] = value;
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
const getMessageType = key => {
return isValidationField(key) && v$.value[getValidationKey(key)]?.$error
? 'error'
: 'info';
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state);
};
const resetValidation = () => {
v$.value.$reset();
};
const resetForm = () => {
Object.assign(state, defaultState);
};
watch(
() => props.contactData?.id,
id => {
if (id) prepareStateBasedOnProps();
},
{ immediate: true }
);
// Expose state to parent component for avatar upload
defineExpose({
state,
resetValidation,
isFormInvalid,
resetForm,
});
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<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-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@update:model-value="handleCountrySelection"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
}`"
class="w-full"
@input="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
@blur="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
/>
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<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"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
}"
>
<Icon
:icon="item.icon"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<input
v-model="
state.additionalAttributes.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"
@input="emit('update', state)"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const contactsFormRef = ref(null);
const contact = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isCreatingContact = computed(() => uiFlags.value.isCreating);
const createNewContact = contactItem => {
contact.value = contactItem;
};
const handleDialogConfirm = async () => {
if (!contact.value) return;
emit('create', contact.value);
};
const onSuccess = () => {
contactsFormRef.value?.resetForm();
dialogRef.value.close();
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleDialogConfirm"
>
<ContactsForm
ref="contactsFormRef"
is-new-contact
@update="createNewContact"
/>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
type="submit"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const FILTER_TYPE_CONTACT = 1;
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isCreating = computed(() => uiFlags.value.isCreating);
const dialogRef = ref(null);
const state = reactive({
name: '',
});
const validationRules = {
name: { required },
};
const v$ = useVuelidate(validationRules, state);
const handleDialogConfirm = async () => {
const isNameValid = await v$.value.$validate();
if (!isNameValid) return;
emit('create', {
name: state.name,
filter_type: FILTER_TYPE_CONTACT,
});
state.name = '';
v$.value.$reset();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
"
:is-loading="isCreating"
:disable-confirm-button="isCreating"
@confirm="handleDialogConfirm"
>
<Input
v-model="state.name"
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
:placeholder="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
"
:message="
v$.name.$error
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
: ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
/>
</Dialog>
</template>

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['delete']);
const FILTER_TYPE_CONTACT = 'contact';
const { t } = useI18n();
const uiFlags = useMapGetter('customViews/getUIFlags');
const isDeleting = computed(() => uiFlags.value.isDeleting);
const dialogRef = ref(null);
const handleDialogConfirm = async () => {
emit('delete', {
filterType: FILTER_TYPE_CONTACT,
});
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
"
:is-loading="isDeleting"
:disable-confirm-button="isDeleting"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import ContactMergeForm from '../ContactMergeForm.vue';
import { contactData, primaryContactList } from './fixtures';
const handleSearch = query => {
console.log('Searching for:', query);
};
const handleUpdate = value => {
console.log('Primary contact updated:', value);
};
</script>
<template>
<Story
title="Components/Contacts/ContactMergeForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="With Selected Primary Contact">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="1"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Error State">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
has-error
error-message="Please select a primary contact"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Empty Primary Contact List">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="[]"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
import ContactsForm from '../ContactsForm.vue';
import { contactData } from './fixtures';
const handleUpdate = updatedData => {
console.log('Form updated:', updatedData);
};
</script>
<template>
<Story
title="Components/Contacts/ContactsForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default without border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm :contact-data="contactData" @update="handleUpdate" />
</div>
</Variant>
<Variant title="Details View with border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="contactData"
is-details-view
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="Minimal Data">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
id: 21,
name: 'Ophelia Folkard',
email: 'ofolkardi@taobao.test',
phoneNumber: '',
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="With All Social Profiles">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
...contactData,
additionalAttributes: {
...contactData.additionalAttributes,
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,47 @@
export const contactData = {
id: 370,
name: 'John Doe',
email: 'johndoe@chatwoot.com',
phoneNumber: '+918634322418',
additionalAttributes: {
city: 'Kerala',
country: 'India',
description: 'Curious about the web.',
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'johndoe',
twitter: 'johndoe',
facebook: 'johndoe',
linkedin: 'johndoe',
instagram: 'johndoe',
},
},
};
export const primaryContactList = [
{
id: 1,
name: 'Jane Smith',
email: 'jane@chatwoot.com',
thumbnail: '',
label: '(ID: 1) Jane Smith',
value: 1,
},
{
id: 2,
name: 'Mike Johnson',
email: 'mike@chatwoot.com',
thumbnail: '',
label: '(ID: 2) Mike Johnson',
value: 2,
},
{
id: 3,
name: 'Sarah Wilson',
email: 'sarah@chatwoot.com',
thumbnail: '',
label: '(ID: 3) Sarah Wilson',
value: 3,
},
];