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,69 @@
<script setup>
import { ref } from 'vue';
import DropdownMenu from './DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const menuItems = [
{ label: 'Edit', action: 'edit', icon: 'edit' },
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
{ label: 'Delete', action: 'delete', icon: 'delete' },
];
const isOpen = ref(false);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const handleAction = () => {
isOpen.value = false;
};
</script>
<template>
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
<Variant title="Default">
<div class="p-4 bg-n-background h-72">
<div class="relative">
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="absolute left-0 top-10"
@action="handleAction"
/>
</div>
</div>
</Variant>
<Variant title="Always Open">
<div class="p-4 bg-n-background h-72">
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
</div>
</Variant>
<Variant title="Custom Items">
<div class="p-4 bg-n-background h-72">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
@action="handleAction"
/>
</div>
</Variant>
<Variant title="With search">
<div class="p-4 bg-n-background h-72">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
show-search
@action="handleAction"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,266 @@
<script setup>
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: {
type: Number,
default: 20,
},
showSearch: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
isSearching: {
type: Boolean,
default: false,
},
labelClass: {
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
const searchInput = ref(null);
const searchQuery = ref('');
const hasSections = computed(() => props.menuSections.length > 0);
const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (props.disableLocalFiltering || !searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
};
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
}
});
</script>
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from './base/DropdownContainer.vue';
import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
const currentUserAutoOffline = ref(false);
const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => console.log('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => console.log('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => console.log('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => console.log('Change Appearance'),
},
{
label: 'Open SuperAdmin',
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => console.log('Log Out'),
},
]);
</script>
<template>
<Story
title="Components/DropdownPrimitives"
:layout="{ type: 'grid', width: 400, height: 800 }"
>
<Variant title="Profile Menu">
<div class="p-4 bg-n-background h-[500px]">
<DropdownContainer>
<template #trigger="{ toggle }">
<Button label="Open Menu" size="sm" @click="toggle" />
</template>
<DropdownBody class="w-80">
<DropdownSection title="Profile Options">
<DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0">
<ToggleSwitch v-model="currentUserAutoOffline" />
</div>
</DropdownItem>
</DropdownSection>
<DropdownSeparator />
<DropdownItem
v-for="item in menuItems"
:key="item.label"
v-bind="item"
/>
</DropdownBody>
</DropdownContainer>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import { computed } from 'vue';
const { strong } = defineProps({
// Use strong prop when this dropdown is stacked inside another dropdown
// Chrome has issues with stacked backdrop-blur, so we need an extra blur layer when stacked
// Also, stacked dropdowns should have a strong border
strong: {
type: Boolean,
default: false,
},
});
const borderClass = computed(() => {
return strong ? 'border-n-strong' : 'border-n-weak';
});
const beforeClass = computed(() => {
if (!strong) return '';
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
// https://issues.chromium.org/issues/40835530
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
});
</script>
<template>
<div class="absolute">
<ul
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base relative"
:class="[borderClass, beforeClass]"
>
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
const [isOpen, toggle] = useToggle(false);
const closeMenu = () => {
if (isOpen.value) {
emit('close');
toggle(false);
}
};
provideDropdownContext({
isOpen,
toggle,
closeMenu,
});
</script>
<template>
<div v-on-click-outside="closeMenu" class="relative space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" class="absolute">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: [String, Object], default: '' },
nativeLink: { type: Boolean, default: false },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
defineOptions({
inheritAttrs: false,
});
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) {
if (props.nativeLink && typeof props.link === 'string') {
return 'a';
}
return 'router-link';
}
if (props.click) return 'button';
return 'div';
});
const triggerClick = () => {
if (props.click) {
props.click();
}
if (!props.preserveOpen) closeMenu();
};
</script>
<template>
<li class="n-dropdown-item">
<component
:is="componentIs"
v-bind="$attrs"
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
:class="{
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
}"
:href="componentIs === 'a' ? props.link : null"
:to="componentIs === 'router-link' ? props.link : null"
@click="triggerClick"
>
<slot>
<slot name="icon">
<Icon v-if="icon" class="size-4 text-n-slate-11" :icon="icon" />
</slot>
<slot name="label">{{ label }}</slot>
</slot>
</component>
</li>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
height: {
type: String,
default: 'max-h-96',
},
});
</script>
<template>
<div class="-mx-2 n-dropdown-section">
<div
v-if="title"
class="px-4 mb-3 mt-1 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
>
{{ title }}
</div>
<ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<slot />
</ul>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<div class="h-0 border-b border-n-strong -mx-2" />
</template>

View File

@@ -0,0 +1,13 @@
import DropdownBody from './DropdownBody.vue';
import DropdownContainer from './DropdownContainer.vue';
import DropdownItem from './DropdownItem.vue';
import DropdownSection from './DropdownSection.vue';
import DropdownSeparator from './DropdownSeparator.vue';
export {
DropdownBody,
DropdownContainer,
DropdownItem,
DropdownSection,
DropdownSeparator,
};

View File

@@ -0,0 +1,19 @@
import { inject, provide } from 'vue';
const DropdownControl = Symbol('DropdownControl');
export function useDropdownContext() {
const context = inject(DropdownControl, null);
if (context === null) {
throw new Error(
`Component is missing a parent <DropdownContainer /> component.`
);
}
return context;
}
export function provideDropdownContext(context) {
provide(DropdownControl, context);
}