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,122 @@
<script setup>
import { computed, defineEmits } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
selectedItem: {
type: Object,
default: () => ({}),
},
hasThumbnail: {
type: Boolean,
default: true,
},
multiselectorTitle: {
type: String,
default: '',
},
multiselectorPlaceholder: {
type: String,
default: 'None',
},
noSearchResult: {
type: String,
default: 'No results found',
},
inputPlaceholder: {
type: String,
default: 'Search',
},
});
const emit = defineEmits(['select']);
const [showSearchDropdown, toggleDropdown] = useToggle(false);
const onCloseDropdown = () => toggleDropdown(false);
const onClickSelectItem = value => {
emit('select', value);
onCloseDropdown();
};
const hasValue = computed(() => {
if (props.selectedItem && props.selectedItem.id) {
return true;
}
return false;
});
</script>
<template>
<OnClickOutside @trigger="onCloseDropdown">
<div class="relative w-full mb-2" @keyup.esc="onCloseDropdown">
<Button
slate
outline
trailing-icon
:icon="
showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="w-full !px-2"
@click="
() => toggleDropdown() // ensure that the event is not passed to the button
"
>
<div class="flex items-center justify-between w-full min-w-0">
<h4 v-if="!hasValue" class="text-sm text-ellipsis text-n-slate-12">
{{ multiselectorPlaceholder }}
</h4>
<h4
v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItem.name"
>
{{ selectedItem.name }}
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail"
:src="selectedItem.thumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:size="24"
hide-offline-status
rounded-full
/>
</Button>
<div
:class="{
'block visible': showSearchDropdown,
'hidden invisible': !showSearchDropdown,
}"
class="box-border top-[2.625rem] w-full border rounded-lg bg-n-alpha-3 backdrop-blur-[100px] absolute shadow-lg border-n-strong dark:border-n-strong p-2 z-[9999]"
>
<div class="flex items-center justify-between mb-1">
<h4
class="m-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
>
{{ multiselectorTitle }}
</h4>
<Button ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
</div>
<MultiselectDropdownItems
v-if="showSearchDropdown"
:options="options"
:selected-items="[selectedItem]"
:has-thumbnail="hasThumbnail"
:input-placeholder="inputPlaceholder"
:no-search-result="noSearchResult"
@select="onClickSelectItem"
/>
</div>
</div>
</OnClickOutside>
</template>

View File

@@ -0,0 +1,151 @@
<script>
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Avatar from 'next/avatar/Avatar.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
Avatar,
NextButton,
},
props: {
options: {
type: Array,
default: () => [],
},
selectedItems: {
type: Array,
default: () => [],
},
hasThumbnail: {
type: Boolean,
default: true,
},
inputPlaceholder: {
type: String,
default: 'Search',
},
noSearchResult: {
type: String,
default: 'No results found',
},
},
emits: ['select'],
data() {
return {
search: '',
};
},
computed: {
filteredOptions() {
return this.options.filter(option => {
return option.name.toLowerCase().includes(this.search.toLowerCase());
});
},
noResult() {
return this.filteredOptions.length === 0 && this.search !== '';
},
},
mounted() {
this.focusInput();
},
methods: {
onclick(option) {
this.$emit('select', option);
},
focusInput() {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => item && option.id === item.id);
},
},
};
</script>
<template>
<div class="dropdown-wrap">
<div class="flex-auto flex-grow-0 flex-shrink-0 mb-2 max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="inputPlaceholder"
/>
</div>
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
trailing-icon
:icon="isActive(option) ? 'i-lucide-check' : ''"
class="w-full !px-2.5"
@click="() => onclick(option)"
>
<div
class="flex items-center justify-between w-full min-w-0 gap-2"
>
<span
class="my-0 overflow-hidden text-sm leading-4 whitespace-nowrap text-ellipsis"
:title="option.name"
>
{{ option.name }}
</span>
</div>
<Avatar
v-if="hasThumbnail"
:src="option.thumbnail"
:name="option.name"
:status="option.availability_status"
:size="24"
hide-offline-status
rounded-full
/>
</NextButton>
</WootDropdownItem>
</WootDropdownMenu>
<h4
v-if="noResult"
class="w-full justify-center items-center flex text-n-slate-10 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
>
{{ noSearchResult }}
</h4>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.dropdown-wrap {
@apply w-full flex flex-col max-h-[12.5rem];
}
.search-input {
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-n-slate-12 rounded-md focus:border-n-brand bg-n-background dark:bg-n-background;
}
.multiselect-dropdown--item {
@apply justify-between w-full;
&.active {
@apply bg-n-slate-2 dark:bg-n-solid-3 border-n-weak/50 dark:border-n-weak font-medium;
}
&:hover {
@apply bg-n-slate-2 dark:bg-n-solid-3 text-n-slate-12;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['add']);
const addLabel = () => {
emit('add');
};
</script>
<template>
<Button
faded
xs
icon="i-lucide-plus"
class="mb-0.5 ltr:mr-0.5 rtl:ml-0.5 !rounded-[4px]"
:label="$t('CONTACT_PANEL.LABELS.CONVERSATION.ADD_BUTTON')"
@click="addLabel"
/>
</template>

View File

@@ -0,0 +1,11 @@
<script>
export default {};
</script>
<template>
<li
class="list-none my-1 mx-0 border-b border-n-weak"
:tabindex="null"
:aria-disabled="true"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script>
export default {
componentName: 'WootDropdownMenu',
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<template>
<li class="inline-flex list-none" :tabindex="null" :aria-disabled="true">
<span
class="text-xs text-n-slate-12 mt-1 font-medium w-full block text-left rtl:text-right whitespace-nowrap p-2"
>
{{ title }}
</span>
<slot />
</li>
</template>

View File

@@ -0,0 +1,46 @@
<script>
export default {
name: 'WootDropdownItem',
componentName: 'WootDropdownMenu',
props: {
disabled: {
type: Boolean,
default: false,
},
},
};
</script>
<template>
<li
class="mb-1 list-none dropdown-menu__item"
:class="{
'is-disabled': disabled,
}"
:tabindex="disabled ? null : -1"
:aria-disabled="disabled"
>
<slot />
</li>
</template>
<style lang="scss" scoped>
.dropdown-menu__item {
::v-deep {
a,
.button {
@apply inline-flex whitespace-nowrap w-full text-left rtl:text-right;
}
}
}
// A hacky fix to remove the background that came from the foundation styles node module file
// Can be removed once we remove the foundation styles node module
.dropdown.menu {
// Top-level item
> li > a {
background: transparent;
padding: 4px 10.8px;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
defineProps({
placement: {
type: String,
default: 'top',
},
});
const dropdownMenuRef = ref(null);
const dropdownMenuButtons = () => {
return dropdownMenuRef.value.querySelectorAll(
'ul.dropdown li.dropdown-menu__item .button'
);
};
const getActiveButtonIndex = menuButtons => {
const focusedButton = dropdownMenuRef.value.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus'
);
return Array.from(menuButtons).indexOf(focusedButton);
};
const focusButton = (menuButtons, newIndex) => {
if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
};
const focusPreviousButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
focusButton(menuButtons, newIndex);
};
const focusNextButton = menuButtons => {
const activeIndex = getActiveButtonIndex(menuButtons);
const newIndex = activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
focusButton(menuButtons, newIndex);
};
const keyboardEvents = {
ArrowUp: {
action: () => focusPreviousButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
ArrowDown: {
action: () => focusNextButton(dropdownMenuButtons()),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<ul
ref="dropdownMenuRef"
class="dropdown menu vertical"
:class="[placement && `dropdown--${placement}`]"
>
<slot />
</ul>
</template>

View File

@@ -0,0 +1,27 @@
<script>
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
components: {
WootDropdownHeader,
},
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<template>
<li class="!mt-0.5">
<ul class="!m-0">
<WootDropdownHeader v-if="title" :title="title" />
<slot />
</ul>
</li>
</template>

View File

@@ -0,0 +1,184 @@
<script>
import LabelDropdownItem from './LabelDropdownItem.vue';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import { picoSearch } from '@scmmishra/pico-search';
import { sanitizeLabel } from 'shared/helpers/sanitizeData';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
LabelDropdownItem,
AddLabelModal,
Hotkey,
NextButton,
},
props: {
accountLabels: {
type: Array,
default: () => [],
},
selectedLabels: {
type: Array,
default: () => [],
},
allowCreation: {
type: Boolean,
default: false,
},
},
emits: ['update', 'add', 'remove'],
data() {
return {
search: '',
createModalVisible: false,
};
},
computed: {
createLabelPlaceholder() {
const label = this.$t('CONTACT_PANEL.LABELS.LABEL_SELECT.CREATE_LABEL');
return this.search ? `${label}:` : label;
},
filteredActiveLabels() {
if (!this.search) return this.accountLabels;
return picoSearch(this.accountLabels, this.search, ['title'], {
threshold: 0.9,
});
},
noResult() {
return this.filteredActiveLabels.length === 0;
},
hasExactMatchInResults() {
return this.filteredActiveLabels.some(
label => label.title === this.search
);
},
shouldShowCreate() {
return this.allowCreation && this.filteredActiveLabels.length < 3;
},
parsedSearch() {
return sanitizeLabel(this.search);
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
updateLabels(label) {
this.$emit('update', label);
},
onAdd(label) {
this.$emit('add', label);
},
onRemove(label) {
this.$emit('remove', label);
},
onAddRemove(label) {
if (this.selectedLabels.includes(label.title)) {
this.onRemove(label.title);
} else {
this.onAdd(label);
}
},
showCreateModal() {
this.createModalVisible = true;
},
hideCreateModal() {
this.createModalVisible = false;
},
},
};
</script>
<template>
<div class="flex flex-col w-full max-h-[12.5rem]">
<div class="flex items-center justify-center mb-1">
<h4
class="flex-grow m-0 overflow-hidden text-sm text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.TITLE') }}
</h4>
<Hotkey
custom-class="border border-solid text-n-slate-12 bg-n-slate-2 text-xxs border-n-strong flex-shrink-0"
>
{{ 'L' }}
</Hotkey>
</div>
<div class="flex-auto flex-grow-0 flex-shrink-0 mb-2 max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')"
/>
</div>
<div
class="flex items-start justify-start flex-auto flex-grow flex-shrink overflow-auto"
>
<div class="w-full my-1">
<woot-dropdown-menu>
<LabelDropdownItem
v-for="label in filteredActiveLabels"
:key="label.title"
:title="label.title"
:color="label.color"
:selected="selectedLabels.includes(label.title)"
@select-label="onAddRemove(label)"
/>
</woot-dropdown-menu>
<div
v-if="noResult"
class="flex justify-center py-4 px-2.5 font-medium text-xs text-n-slate-11"
>
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.NO_RESULT') }}
</div>
<div
v-if="allowCreation && shouldShowCreate"
class="flex pt-1 border-t border-solid border-n-weak"
>
<NextButton
icon="i-lucide-plus"
slate
sm
ghost
:label="`${createLabelPlaceholder} ${parsedSearch}`"
:disabled="hasExactMatchInResults"
@click="showCreateModal"
/>
<woot-modal
v-model:show="createModalVisible"
:on-close="hideCreateModal"
>
<AddLabelModal
:prefill-title="parsedSearch"
@close="hideCreateModal"
/>
</woot-modal>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
title: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
},
emits: ['selectLabel'],
methods: {
onClick() {
this.$emit('selectLabel', this.title);
},
},
};
</script>
<template>
<woot-dropdown-item>
<NextButton
slate
ghost
blue
trailing-icon
:icon="selected ? 'i-lucide-circle-check' : ''"
class="w-full !px-2.5 justify-between"
:class="{ '!flex-row': !selected }"
@click="onClick"
>
<div class="flex items-center min-w-0 gap-2">
<div
v-if="color"
class="size-3 flex-shrink-0 rounded-full outline outline-1 outline-n-weak"
:style="{ backgroundColor: color }"
/>
<span
class="overflow-hidden text-ellipsis whitespace-nowrap leading-[1.1]"
:title="title"
>
{{ title }}
</span>
</div>
</NextButton>
</woot-dropdown-item>
</template>