Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const reauthorizationRequired = computed(() => {
|
||||
return props.inbox.reauthorization_required;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="size-4 grid place-content-center rounded-full">
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<div
|
||||
v-if="reauthorizationRequired"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
class="grid place-content-center size-5 bg-n-ruby-5/60 rounded-full"
|
||||
>
|
||||
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
|
||||
defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isConversationRoute = computed(() => {
|
||||
const CONVERSATION_ROUTES = [
|
||||
'inbox_conversation',
|
||||
'conversation_through_inbox',
|
||||
'conversations_through_label',
|
||||
'team_conversations_through_label',
|
||||
'conversations_through_folders',
|
||||
'conversation_through_mentions',
|
||||
'conversation_through_unattended',
|
||||
'conversation_through_participating',
|
||||
'inbox_view_conversation',
|
||||
];
|
||||
return CONVERSATION_ROUTES.includes(route.name);
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
emit('toggle');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isConversationRoute"
|
||||
id="mobile-sidebar-launcher"
|
||||
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
|
||||
:class="[
|
||||
{
|
||||
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ButtonGroup
|
||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-menu"
|
||||
no-animation
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||
lg
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,876 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import SidebarChangelogButton from './SidebarChangelogButton.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'closeKeyShortcutModal',
|
||||
'openKeyShortcutModal',
|
||||
'showCreateAccountModal',
|
||||
'closeMobileSidebar',
|
||||
]);
|
||||
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
);
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
} else {
|
||||
emit('closeKeyShortcutModal');
|
||||
}
|
||||
};
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
const expandedItem = ref(null);
|
||||
|
||||
const setExpandedItem = name => {
|
||||
expandedItem.value = expandedItem.value === name ? null : name;
|
||||
};
|
||||
|
||||
const {
|
||||
sidebarWidth,
|
||||
isCollapsed,
|
||||
setSidebarWidth,
|
||||
saveWidth,
|
||||
snapToCollapsed,
|
||||
snapToExpanded,
|
||||
COLLAPSED_THRESHOLD,
|
||||
} = useSidebarResize();
|
||||
|
||||
// On mobile, sidebar is always expanded (flyout mode)
|
||||
const isEffectivelyCollapsed = computed(
|
||||
() => !isMobile.value && isCollapsed.value
|
||||
);
|
||||
|
||||
// Resize handle logic
|
||||
const isResizing = ref(false);
|
||||
const startX = ref(0);
|
||||
const startWidth = ref(0);
|
||||
|
||||
provideSidebarContext({
|
||||
expandedItem,
|
||||
setExpandedItem,
|
||||
isCollapsed: isEffectivelyCollapsed,
|
||||
sidebarWidth,
|
||||
isResizing,
|
||||
});
|
||||
|
||||
// Get clientX from mouse or touch event
|
||||
const getClientX = event =>
|
||||
event.touches ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
const onResizeStart = event => {
|
||||
isResizing.value = true;
|
||||
startX.value = getClientX(event);
|
||||
startWidth.value = sidebarWidth.value;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'col-resize',
|
||||
userSelect: 'none',
|
||||
});
|
||||
// Prevent default to avoid scrolling on touch
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onResizeMove = event => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
const delta = isRTL.value
|
||||
? startX.value - getClientX(event)
|
||||
: getClientX(event) - startX.value;
|
||||
setSidebarWidth(startWidth.value + delta);
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
isResizing.value = false;
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
|
||||
// Snap to collapsed state if below threshold
|
||||
if (sidebarWidth.value < COLLAPSED_THRESHOLD) {
|
||||
snapToCollapsed();
|
||||
} else {
|
||||
saveWidth();
|
||||
}
|
||||
};
|
||||
|
||||
const onResizeHandleDoubleClick = () => {
|
||||
if (isCollapsed.value) snapToExpanded();
|
||||
else snapToCollapsed();
|
||||
};
|
||||
|
||||
// Support both mouse and touch events
|
||||
useEventListener(document, 'mousemove', onResizeMove);
|
||||
useEventListener(document, 'mouseup', onResizeEnd);
|
||||
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
|
||||
useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('notifications/unReadCount');
|
||||
store.dispatch('teams/get');
|
||||
store.dispatch('attributes/get');
|
||||
store.dispatch('customViews/get', 'conversation');
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!props.isMobileSidebarOpen) return;
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports_index'),
|
||||
activeOn: ['agent_reports_show'],
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports_index'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports_index'),
|
||||
activeOn: ['inbox_reports_show'],
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports_index'),
|
||||
activeOn: ['team_reports_show'],
|
||||
},
|
||||
];
|
||||
|
||||
const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
label: t('SIDEBAR.CONVERSATIONS'),
|
||||
icon: 'i-lucide-message-circle',
|
||||
children: [
|
||||
{
|
||||
name: 'All',
|
||||
label: t('SIDEBAR.ALL_CONVERSATIONS'),
|
||||
activeOn: ['inbox_conversation'],
|
||||
to: accountScopedRoute('home'),
|
||||
},
|
||||
{
|
||||
name: 'Mentions',
|
||||
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
|
||||
activeOn: ['conversation_through_mentions'],
|
||||
to: accountScopedRoute('conversation_mentions'),
|
||||
},
|
||||
{
|
||||
name: 'Unattended',
|
||||
activeOn: ['conversation_through_unattended'],
|
||||
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
|
||||
to: accountScopedRoute('conversation_unattended'),
|
||||
},
|
||||
{
|
||||
name: 'Folders',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Teams',
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
children: teams.value.map(team => ({
|
||||
name: `${team.name}-${team.id}`,
|
||||
label: team.name,
|
||||
to: accountScopedRoute('team_conversations', { teamId: team.id }),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Channels',
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
h(ChannelLeaf, {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Labels',
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute('label_conversations', {
|
||||
label: label.title,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
activeOn: ['captain_assistants_create_index'],
|
||||
children: [
|
||||
{
|
||||
name: 'FAQs',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
activeOn: [
|
||||
'captain_assistants_responses_index',
|
||||
'captain_assistants_responses_pending',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_responses_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||
activeOn: ['captain_assistants_documents_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_documents_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Scenarios',
|
||||
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
|
||||
activeOn: ['captain_assistants_scenarios_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_scenarios_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
||||
activeOn: ['captain_assistants_playground_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_playground_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Inboxes',
|
||||
label: t('SIDEBAR.CAPTAIN_INBOXES'),
|
||||
activeOn: ['captain_assistants_inboxes_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
activeOn: [
|
||||
'captain_assistants_settings_index',
|
||||
'captain_assistants_guidelines_index',
|
||||
'captain_assistants_guardrails_index',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_settings_index',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Contacts',
|
||||
label: t('SIDEBAR.CONTACTS'),
|
||||
icon: 'i-lucide-contact',
|
||||
children: [
|
||||
{
|
||||
name: 'All Contacts',
|
||||
label: t('SIDEBAR.ALL_CONTACTS'),
|
||||
to: accountScopedRoute(
|
||||
'contacts_dashboard_index',
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
|
||||
},
|
||||
{
|
||||
name: 'Active',
|
||||
label: t('SIDEBAR.ACTIVE'),
|
||||
to: accountScopedRoute('contacts_dashboard_active'),
|
||||
activeOn: ['contacts_dashboard_active'],
|
||||
},
|
||||
{
|
||||
name: 'Segments',
|
||||
icon: 'i-lucide-group',
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
|
||||
children: contactCustomViews.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute(
|
||||
'contacts_dashboard_segments_index',
|
||||
{ segmentId: view.id },
|
||||
{ page: 1 }
|
||||
),
|
||||
activeOn: [
|
||||
'contacts_dashboard_segments_index',
|
||||
'contacts_edit_segment',
|
||||
],
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'Tagged With',
|
||||
icon: 'i-lucide-tag',
|
||||
label: t('SIDEBAR.TAGGED_WITH'),
|
||||
children: labels.value.map(label => ({
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute(
|
||||
'contacts_dashboard_labels_index',
|
||||
{ label: label.title },
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: [
|
||||
'contacts_dashboard_labels_index',
|
||||
'contacts_edit_label',
|
||||
],
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Companies',
|
||||
label: t('SIDEBAR.COMPANIES'),
|
||||
icon: 'i-lucide-building-2',
|
||||
children: [
|
||||
{
|
||||
name: 'All Companies',
|
||||
label: t('SIDEBAR.ALL_COMPANIES'),
|
||||
to: accountScopedRoute(
|
||||
'companies_dashboard_index',
|
||||
{},
|
||||
{ page: 1, search: undefined }
|
||||
),
|
||||
activeOn: ['companies_dashboard_index'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
label: t('SIDEBAR.REPORTS'),
|
||||
icon: 'i-lucide-chart-spline',
|
||||
children: [
|
||||
{
|
||||
name: 'Report Overview',
|
||||
label: t('SIDEBAR.REPORTS_OVERVIEW'),
|
||||
to: accountScopedRoute('account_overview_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Report Conversation',
|
||||
label: t('SIDEBAR.REPORTS_CONVERSATION'),
|
||||
to: accountScopedRoute('conversation_reports'),
|
||||
},
|
||||
...reportRoutes.value,
|
||||
{
|
||||
name: 'Reports CSAT',
|
||||
label: t('SIDEBAR.CSAT'),
|
||||
to: accountScopedRoute('csat_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports SLA',
|
||||
label: t('SIDEBAR.REPORTS_SLA'),
|
||||
to: accountScopedRoute('sla_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Bot',
|
||||
label: t('SIDEBAR.REPORTS_BOT'),
|
||||
to: accountScopedRoute('bot_reports'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Campaigns',
|
||||
label: t('SIDEBAR.CAMPAIGNS'),
|
||||
icon: 'i-lucide-megaphone',
|
||||
children: [
|
||||
{
|
||||
name: 'Live chat',
|
||||
label: t('SIDEBAR.LIVE_CHAT'),
|
||||
to: accountScopedRoute('campaigns_livechat_index'),
|
||||
},
|
||||
{
|
||||
name: 'SMS',
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
label: t('SIDEBAR.WHATSAPP'),
|
||||
to: accountScopedRoute('campaigns_whatsapp_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Portals',
|
||||
label: t('SIDEBAR.HELP_CENTER.TITLE'),
|
||||
icon: 'i-lucide-library-big',
|
||||
children: [
|
||||
{
|
||||
name: 'Articles',
|
||||
label: t('SIDEBAR.HELP_CENTER.ARTICLES'),
|
||||
activeOn: [
|
||||
'portals_articles_index',
|
||||
'portals_articles_new',
|
||||
'portals_articles_edit',
|
||||
],
|
||||
to: accountScopedRoute('portals_index', {
|
||||
navigationPath: 'portals_articles_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Categories',
|
||||
label: t('SIDEBAR.HELP_CENTER.CATEGORIES'),
|
||||
activeOn: [
|
||||
'portals_categories_index',
|
||||
'portals_categories_articles_index',
|
||||
'portals_categories_articles_edit',
|
||||
],
|
||||
to: accountScopedRoute('portals_index', {
|
||||
navigationPath: 'portals_categories_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Locales',
|
||||
label: t('SIDEBAR.HELP_CENTER.LOCALES'),
|
||||
activeOn: ['portals_locales_index'],
|
||||
to: accountScopedRoute('portals_index', {
|
||||
navigationPath: 'portals_locales_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.HELP_CENTER.SETTINGS'),
|
||||
activeOn: ['portals_settings_index'],
|
||||
to: accountScopedRoute('portals_index', {
|
||||
navigationPath: 'portals_settings_index',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.SETTINGS'),
|
||||
icon: 'i-lucide-bolt',
|
||||
children: [
|
||||
{
|
||||
name: 'Settings Account Settings',
|
||||
label: t('SIDEBAR.ACCOUNT_SETTINGS'),
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('general_settings_index'),
|
||||
},
|
||||
// {
|
||||
// name: 'Settings Captain',
|
||||
// label: t('SIDEBAR.CAPTAIN_AI'),
|
||||
// icon: 'i-woot-captain',
|
||||
// to: accountScopedRoute('captain_settings_index'),
|
||||
// },
|
||||
{
|
||||
name: 'Settings Agents',
|
||||
label: t('SIDEBAR.AGENTS'),
|
||||
icon: 'i-lucide-square-user',
|
||||
to: accountScopedRoute('agent_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Teams',
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: [
|
||||
'settings_teams_list',
|
||||
'settings_teams_new',
|
||||
'settings_teams_finish',
|
||||
'settings_teams_add_agents',
|
||||
'settings_teams_show',
|
||||
'settings_teams_edit',
|
||||
'settings_teams_edit_members',
|
||||
'settings_teams_edit_finish',
|
||||
],
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
...(hasAdvancedAssignment.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
activeOn: [
|
||||
'assignment_policy_index',
|
||||
'agent_assignment_policy_index',
|
||||
'agent_assignment_policy_create',
|
||||
'agent_assignment_policy_edit',
|
||||
'agent_capacity_policy_index',
|
||||
'agent_capacity_policy_create',
|
||||
'agent_capacity_policy_edit',
|
||||
],
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
icon: 'i-lucide-inbox',
|
||||
activeOn: [
|
||||
'settings_inbox_list',
|
||||
'settings_inbox_show',
|
||||
'settings_inbox_new',
|
||||
'settings_inbox_finish',
|
||||
'settings_inboxes_page_channel',
|
||||
'settings_inboxes_add_agents',
|
||||
],
|
||||
to: accountScopedRoute('settings_inbox_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Labels',
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tags',
|
||||
to: accountScopedRoute('labels_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Custom Attributes',
|
||||
label: t('SIDEBAR.CUSTOM_ATTRIBUTES'),
|
||||
icon: 'i-lucide-code',
|
||||
to: accountScopedRoute('attributes_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Automation',
|
||||
label: t('SIDEBAR.AUTOMATION'),
|
||||
icon: 'i-lucide-repeat',
|
||||
to: accountScopedRoute('automation_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agent Bots',
|
||||
label: t('SIDEBAR.AGENT_BOTS'),
|
||||
icon: 'i-lucide-bot',
|
||||
to: accountScopedRoute('agent_bots'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Macros',
|
||||
label: t('SIDEBAR.MACROS'),
|
||||
icon: 'i-lucide-toy-brick',
|
||||
to: accountScopedRoute('macros_wrapper'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Canned Responses',
|
||||
label: t('SIDEBAR.CANNED_RESPONSES'),
|
||||
icon: 'i-lucide-message-square-quote',
|
||||
to: accountScopedRoute('canned_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Integrations',
|
||||
label: t('SIDEBAR.INTEGRATIONS'),
|
||||
icon: 'i-lucide-blocks',
|
||||
to: accountScopedRoute('settings_applications'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Audit Logs',
|
||||
label: t('SIDEBAR.AUDIT_LOGS'),
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('auditlogs_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Custom Roles',
|
||||
label: t('SIDEBAR.CUSTOM_ROLES'),
|
||||
icon: 'i-lucide-shield-plus',
|
||||
to: accountScopedRoute('custom_roles_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Sla',
|
||||
label: t('SIDEBAR.SLA'),
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('sla_list'),
|
||||
},
|
||||
{
|
||||
name: 'Conversation Workflow',
|
||||
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
|
||||
icon: 'i-lucide-workflow',
|
||||
to: accountScopedRoute('conversation_workflow_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Security',
|
||||
label: t('SIDEBAR.SECURITY'),
|
||||
icon: 'i-lucide-shield',
|
||||
to: accountScopedRoute('security_settings_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
to: accountScopedRoute('billing_settings_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
]"
|
||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
{
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||
'transition-transform duration-200 ease-out md:transition-[width]':
|
||||
!isResizing,
|
||||
},
|
||||
]"
|
||||
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
|
||||
>
|
||||
<section
|
||||
class="grid"
|
||||
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-center min-w-0"
|
||||
:class="{
|
||||
'justify-center px-1': isEffectivelyCollapsed,
|
||||
'px-2': !isEffectivelyCollapsed,
|
||||
}"
|
||||
>
|
||||
<template v-if="isEffectivelyCollapsed">
|
||||
<SidebarAccountSwitcher
|
||||
is-collapsed
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="!isEffectivelyCollapsed"
|
||||
:to="{ name: 'search' }"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
|
||||
>
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
|
||||
<span class="flex-grow text-start text-n-slate-10">
|
||||
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
||||
</span>
|
||||
<span
|
||||
class="hidden tracking-wide pointer-events-none select-none text-n-slate-10"
|
||||
>
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="{ name: 'search' }"
|
||||
class="flex items-center justify-center size-8 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out hover:bg-n-alpha-2 dark:hover:bg-n-slate-9/30"
|
||||
:title="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
>
|
||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="dark:hover:!bg-n-slate-9/30"
|
||||
:class="[
|
||||
isEffectivelyCollapsed
|
||||
? '!size-8 !outline-n-weak !text-n-slate-11'
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
]"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</section>
|
||||
<nav
|
||||
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
|
||||
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
|
||||
>
|
||||
<ul
|
||||
class="flex flex-col gap-1 m-0 list-none min-w-0"
|
||||
:class="{ 'items-center': isEffectivelyCollapsed }"
|
||||
>
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
v-bind="item"
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
|
||||
/>
|
||||
<SidebarChangelogCard
|
||||
v-if="
|
||||
isOnChatwootCloud &&
|
||||
!isACustomBrandedInstance &&
|
||||
!isEffectivelyCollapsed
|
||||
"
|
||||
/>
|
||||
<SidebarChangelogButton
|
||||
v-if="
|
||||
isOnChatwootCloud &&
|
||||
!isACustomBrandedInstance &&
|
||||
isEffectivelyCollapsed
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
:is-collapsed="isEffectivelyCollapsed"
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Resize Handle (desktop only) -->
|
||||
<div
|
||||
class="hidden md:block absolute top-0 h-full w-1 cursor-col-resize z-40 ltr:right-0 rtl:left-0 group"
|
||||
@mousedown="onResizeStart"
|
||||
@touchstart="onResizeStart"
|
||||
@dblclick="onResizeHandleDoubleClick"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 h-full w-px ltr:right-0 rtl:left-0 bg-transparent group-hover:bg-n-brand transition-colors"
|
||||
:class="{ 'bg-n-brand': isResizing }"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSection,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
|
||||
defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showCreateAccountModal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { accountId, currentAccount } = useAccount();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const userAccounts = useMapGetter('getUserAccounts');
|
||||
|
||||
const showAccountSwitcher = computed(
|
||||
() => userAccounts.value.length > 1 && currentAccount.value.name
|
||||
);
|
||||
|
||||
const sortedCurrentUserAccounts = computed(() => {
|
||||
return [...(currentUser.value.accounts || [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
});
|
||||
|
||||
const onChangeAccount = newId => {
|
||||
const accountUrl = `/app/accounts/${newId}/dashboard`;
|
||||
window.location.href = accountUrl;
|
||||
};
|
||||
|
||||
const emitNewAccount = () => {
|
||||
emit('showCreateAccountModal');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<!-- Collapsed view: Logo trigger -->
|
||||
<button
|
||||
v-if="isCollapsed"
|
||||
class="grid flex-shrink-0 place-content-center p-2 rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
:title="currentAccount.name"
|
||||
@click="toggle"
|
||||
>
|
||||
<Logo class="size-7" />
|
||||
</button>
|
||||
<!-- Expanded view: Account name trigger -->
|
||||
<button
|
||||
v-else
|
||||
id="sidebar-account-switcher"
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="account-options"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg px-2"
|
||||
:class="[
|
||||
isOpen && 'bg-n-alpha-1',
|
||||
showAccountSwitcher
|
||||
? 'hover:bg-n-alpha-1 cursor-pointer'
|
||||
: 'cursor-default',
|
||||
]"
|
||||
@click="() => showAccountSwitcher && toggle()"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ currentAccount.name }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showAccountSwitcher"
|
||||
aria-hidden="true"
|
||||
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody
|
||||
v-if="showAccountSwitcher || isCollapsed"
|
||||
class="min-w-80 z-50"
|
||||
>
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||
<DropdownItem
|
||||
v-for="account in sortedCurrentUserAccounts"
|
||||
:id="`account-${account.id}`"
|
||||
:key="account.id"
|
||||
class="cursor-pointer"
|
||||
@click="onChangeAccount(account.id)"
|
||||
>
|
||||
<template #label>
|
||||
<div
|
||||
:for="account.name"
|
||||
class="text-left rtl:text-right flex gap-2 items-center"
|
||||
>
|
||||
<span
|
||||
class="text-n-slate-12 max-w-36 truncate min-w-0"
|
||||
:title="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</span>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<span
|
||||
class="text-n-slate-11 max-w-24 truncate capitalize"
|
||||
:title="account.name"
|
||||
>
|
||||
{{
|
||||
account.custom_role_id
|
||||
? account.custom_role.name
|
||||
: account.role
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-show="account.id === accountId"
|
||||
icon="i-lucide-check"
|
||||
class="text-n-teal-11 size-5"
|
||||
/>
|
||||
</template>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
|
||||
<ButtonNext
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="emitNewAccount"
|
||||
>
|
||||
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
|
||||
</ButtonNext>
|
||||
</DropdownItem>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
|
||||
const [isOpen, toggleOpen] = useToggle(false);
|
||||
const changelogCard = useTemplateRef('changelogCard');
|
||||
|
||||
const isLoading = computed(() => changelogCard.value?.isLoading || false);
|
||||
const hasArticles = computed(
|
||||
() => changelogCard.value?.unDismissedPosts?.length > 0
|
||||
);
|
||||
const shouldShowButton = computed(() => !isLoading.value && hasArticles.value);
|
||||
|
||||
const closePopover = () => {
|
||||
if (isOpen.value) {
|
||||
toggleOpen(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-click-outside="closePopover" class="relative mb-2">
|
||||
<Button
|
||||
v-if="shouldShowButton"
|
||||
icon="i-lucide-sparkles"
|
||||
ghost
|
||||
slate
|
||||
:class="{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen }"
|
||||
@click="toggleOpen()"
|
||||
/>
|
||||
|
||||
<!-- Always render card so it can fetch data, control visibility with v-show -->
|
||||
<div
|
||||
v-show="isOpen && hasArticles"
|
||||
class="absolute ltr:left-full rtl:right-full bottom-0 ltr:ml-4 rtl:mr-4 z-40 bg-transparent w-52"
|
||||
>
|
||||
<SidebarChangelogCard
|
||||
ref="changelogCard"
|
||||
class="[&>div]:!pb-0 [&>div]:!px-0 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import changelogAPI from 'dashboard/api/changelog';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const MAX_DISMISSED_SLUGS = 5;
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const posts = ref([]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Get current dismissed slugs from ui_settings
|
||||
const dismissedSlugs = computed(() => {
|
||||
return uiSettings.value.changelog_dismissed_slugs || [];
|
||||
});
|
||||
|
||||
// Get un dismissed posts - these are the changelog posts that should be shown
|
||||
const unDismissedPosts = computed(() => {
|
||||
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
|
||||
});
|
||||
|
||||
// Fetch changelog posts from API
|
||||
const fetchChangelog = async () => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await changelogAPI.fetchFromHub();
|
||||
posts.value = response.data.posts || [];
|
||||
|
||||
// Clean up dismissed slugs - remove any that are no longer in the current feed
|
||||
const currentSlugs = posts.value.map(post => post.slug);
|
||||
const cleanedDismissedSlugs = dismissedSlugs.value.filter(slug =>
|
||||
currentSlugs.includes(slug)
|
||||
);
|
||||
|
||||
// Update ui_settings if cleanup occurred
|
||||
if (cleanedDismissedSlugs.length !== dismissedSlugs.value.length) {
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: cleanedDismissedSlugs,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (err) {
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dismiss a changelog post
|
||||
const dismissPost = slug => {
|
||||
const currentDismissed = [...dismissedSlugs.value];
|
||||
|
||||
// Add new slug if not already present
|
||||
if (!currentDismissed.includes(slug)) {
|
||||
currentDismissed.push(slug);
|
||||
|
||||
// Keep only the most recent MAX_DISMISSED_SLUGS entries
|
||||
if (currentDismissed.length > MAX_DISMISSED_SLUGS) {
|
||||
currentDismissed.shift(); // Remove oldest entry
|
||||
}
|
||||
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: currentDismissed,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
dismissPost(slug);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= unDismissedPosts.value.length)
|
||||
currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
const currentPost = unDismissedPosts.value[currentIndex.value];
|
||||
if (currentPost?.slug) {
|
||||
window.open(`https://www.chatwoot.com/blog/${currentPost.slug}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgClick = ({ index }) => {
|
||||
currentIndex.value = index;
|
||||
handleReadMore();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isLoading,
|
||||
unDismissedPosts,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchChangelog();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GroupedStackedChangelogCard
|
||||
v-if="unDismissedPosts.length > 0"
|
||||
v-bind="$attrs"
|
||||
:posts="unDismissedPosts"
|
||||
:current-index="currentIndex"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[240px] z-10"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
children: { type: Array, default: () => [] },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||
|
||||
const router = useRouter();
|
||||
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||
|
||||
const expandedSubGroup = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
const topPosition = ref(0);
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
const skipTransition = ref(true);
|
||||
|
||||
const toggleSubGroup = name => {
|
||||
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||
};
|
||||
|
||||
const navigateAndClose = to => {
|
||||
router.push(to);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const isActive = child => props.activeChild?.name === child.name;
|
||||
|
||||
const getAccessibleSubChildren = children =>
|
||||
children.filter(c => isAllowed(c.to));
|
||||
|
||||
const renderIcon = icon => ({
|
||||
component: typeof icon === 'object' ? icon : Icon,
|
||||
props: typeof icon === 'string' ? { icon } : null,
|
||||
});
|
||||
|
||||
const transition = computed(() =>
|
||||
skipTransition.value
|
||||
? {}
|
||||
: {
|
||||
enterActiveClass: 'transition-all duration-200 ease-out',
|
||||
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||
enterToClass: 'opacity-100 translate-y-0 max-h-96',
|
||||
leaveActiveClass: 'transition-all duration-150 ease-in',
|
||||
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
|
||||
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||
}
|
||||
);
|
||||
|
||||
const accessibleChildren = computed(() => {
|
||||
return props.children.filter(child => {
|
||||
if (child.children) {
|
||||
return child.children.some(subChild => isAllowed(subChild.to));
|
||||
}
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
// Auto-expand subgroup if active child is inside it
|
||||
if (props.activeChild) {
|
||||
const parentGroup = props.children.find(child =>
|
||||
child.children?.some(subChild => subChild.name === props.activeChild.name)
|
||||
);
|
||||
if (parentGroup) {
|
||||
expandedSubGroup.value = parentGroup.name;
|
||||
// Wait for the subgroup expansion to render before measuring height
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.triggerRect) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const popoverHeight = popoverRef.value?.offsetHeight || 300;
|
||||
const { top: triggerTop } = props.triggerRect;
|
||||
|
||||
// Adjust position if popover would overflow viewport
|
||||
topPosition.value =
|
||||
triggerTop + popoverHeight > viewportHeight - 20
|
||||
? Math.max(20, viewportHeight - popoverHeight - 20)
|
||||
: triggerTop;
|
||||
|
||||
await nextTick();
|
||||
skipTransition.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection>
|
||||
<div
|
||||
ref="popoverRef"
|
||||
class="fixed z-[100] min-w-[200px] max-w-[280px]"
|
||||
:style="{
|
||||
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
|
||||
top: `${topPosition}px`,
|
||||
}"
|
||||
@mouseenter="emit('mouseenter')"
|
||||
@mouseleave="emit('mouseleave')"
|
||||
>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
|
||||
>
|
||||
<div
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
<ul
|
||||
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<template v-for="child in accessibleChildren" :key="child.name">
|
||||
<!-- SubGroup with children -->
|
||||
<li v-if="child.children" class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
<span
|
||||
class="size-3 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<Transition v-bind="transition">
|
||||
<ul
|
||||
v-if="expandedSubGroup === child.name"
|
||||
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
|
||||
>
|
||||
<li
|
||||
v-for="subChild in getAccessibleSubChildren(child.children)"
|
||||
:key="subChild.name"
|
||||
class="py-0.5"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
|
||||
'text-n-slate-11 hover:bg-n-alpha-2':
|
||||
!isActive(subChild),
|
||||
}"
|
||||
@click="navigateAndClose(subChild.to)"
|
||||
>
|
||||
<component
|
||||
:is="renderIcon(subChild.icon).component"
|
||||
v-if="subChild.icon"
|
||||
v-bind="renderIcon(subChild.icon).props"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<!-- Direct child item -->
|
||||
<li v-else class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive(child),
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
|
||||
}"
|
||||
@click="navigateAndClose(child.to)"
|
||||
>
|
||||
<component
|
||||
:is="renderIcon(child.icon).component"
|
||||
v-if="child.icon"
|
||||
v-bind="renderIcon(child.icon).props"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
|
||||
import { useSidebarContext, usePopoverState } from './provider';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarSubGroup from './SidebarSubGroup.vue';
|
||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [String, Object, Function], default: null },
|
||||
to: { type: Object, default: null },
|
||||
activeOn: { type: Array, default: () => [] },
|
||||
children: { type: Array, default: undefined },
|
||||
getterKeys: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const {
|
||||
expandedItem,
|
||||
setExpandedItem,
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
isCollapsed,
|
||||
isResizing,
|
||||
} = useSidebarContext();
|
||||
|
||||
const {
|
||||
activePopover,
|
||||
setActivePopover,
|
||||
closeActivePopover,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
} = usePopoverState();
|
||||
|
||||
const navigableChildren = computed(() => {
|
||||
return props.children?.flatMap(child => child.children || child) || [];
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isExpanded = computed(() => expandedItem.value === props.name);
|
||||
const isExpandable = computed(() => props.children);
|
||||
const hasChildren = computed(
|
||||
() => Array.isArray(props.children) && props.children.length > 0
|
||||
);
|
||||
|
||||
// Use shared popover state - only one popover can be open at a time
|
||||
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||
|
||||
const openPopover = () => {
|
||||
if (triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
triggerRect.value = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
bottom: rect.bottom,
|
||||
right: rect.right,
|
||||
};
|
||||
}
|
||||
setActivePopover(props.name);
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
if (activePopover.value === props.name) {
|
||||
closeActivePopover();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!hasChildren.value || isResizing.value) return;
|
||||
cancelClose();
|
||||
openPopover();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasChildren.value) return;
|
||||
scheduleClose(200);
|
||||
};
|
||||
|
||||
const handlePopoverMouseEnter = () => {
|
||||
cancelClose();
|
||||
};
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
scheduleClose(100);
|
||||
};
|
||||
|
||||
// Close popover when mouse leaves the window
|
||||
const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
};
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
return props.children.filter(child => {
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
const hasAccessibleChildren = computed(() => {
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.to) {
|
||||
if (route.path === resolvePath(props.to)) return true;
|
||||
|
||||
return props.activeOn.includes(route.name);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// We could use the RouterLink isActive too, but our routes are not always
|
||||
// nested correctly, so we need to check the active state ourselves
|
||||
// TODO: Audit the routes and fix the nesting and remove this
|
||||
const activeChild = computed(() => {
|
||||
const pathSame = navigableChildren.value.find(
|
||||
child => child.to && route.path === resolvePath(child.to)
|
||||
);
|
||||
if (pathSame) return pathSame;
|
||||
|
||||
// Rank the activeOn Prop higher than the path match
|
||||
// There will be cases where the path name is the same but the params are different
|
||||
// So we need to rank them based on the params
|
||||
// For example, contacts segment list in the sidebar effectively has the same name
|
||||
// But the params are different
|
||||
const activeOnPages = navigableChildren.value.filter(child =>
|
||||
child.activeOn?.includes(route.name)
|
||||
);
|
||||
|
||||
if (activeOnPages.length > 0) {
|
||||
const rankedPage = activeOnPages.find(child => {
|
||||
return Object.keys(child.to.params)
|
||||
.map(key => {
|
||||
return String(child.to.params[key]) === String(route.params[key]);
|
||||
})
|
||||
.every(match => match);
|
||||
});
|
||||
|
||||
// If there is no ranked page, return the first activeOn page anyway
|
||||
// Since this takes higher precedence over the path match
|
||||
// This is not perfect, ideally we should rank each route based on all the techniques
|
||||
// and then return the highest ranked one
|
||||
// But this is good enough for now
|
||||
return rankedPage ?? activeOnPages[0];
|
||||
}
|
||||
|
||||
return navigableChildren.value.find(
|
||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||
);
|
||||
});
|
||||
|
||||
const hasActiveChild = computed(() => {
|
||||
return activeChild.value !== undefined;
|
||||
});
|
||||
|
||||
const handleCollapsedClick = () => {
|
||||
if (hasChildren.value && hasAccessibleChildren.value) {
|
||||
const firstItem = accessibleItems.value[0];
|
||||
router.push(firstItem.to);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTrigger = () => {
|
||||
if (
|
||||
hasAccessibleChildren.value &&
|
||||
!isExpanded.value &&
|
||||
!hasActiveChild.value
|
||||
) {
|
||||
// if not already expanded, navigate to the first child
|
||||
const firstItem = accessibleItems.value[0];
|
||||
router.push(firstItem.to);
|
||||
}
|
||||
setExpandedItem(props.name);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (hasActiveChild.value) {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
document.addEventListener('mouseleave', handleWindowBlur);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
document.removeEventListener('mouseleave', handleWindowBlur);
|
||||
});
|
||||
|
||||
watch(
|
||||
hasActiveChild,
|
||||
hasNewActiveChild => {
|
||||
if (hasNewActiveChild && !isExpanded.value) {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
v-if="!hasChildren || hasAccessibleChildren"
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
|
||||
>
|
||||
<!-- Collapsed State -->
|
||||
<template v-if="isCollapsed">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<component
|
||||
:is="to && !hasChildren ? 'router-link' : 'button'"
|
||||
ref="triggerRef"
|
||||
:to="to && !hasChildren ? to : undefined"
|
||||
type="button"
|
||||
class="flex items-center justify-center size-10 rounded-lg"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||
}"
|
||||
:title="label"
|
||||
@click="hasChildren ? handleCollapsedClick() : undefined"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
</component>
|
||||
<SidebarCollapsedPopover
|
||||
v-if="hasChildren && isPopoverOpen"
|
||||
:label="label"
|
||||
:children="children"
|
||||
:active-child="activeChild"
|
||||
:trigger-rect="triggerRect"
|
||||
@close="closePopover"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Expanded State -->
|
||||
<template v-else>
|
||||
<SidebarGroupHeader
|
||||
:icon
|
||||
:name
|
||||
:label
|
||||
:to
|
||||
:getter-keys="getterKeys"
|
||||
:is-active="isActive"
|
||||
:has-active-child="hasActiveChild"
|
||||
:expandable="hasChildren"
|
||||
:is-expanded="isExpanded"
|
||||
@toggle="toggleTrigger"
|
||||
/>
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-else-if="isExpandable && isExpanded">
|
||||
<SidebarGroupEmptyLeaf />
|
||||
</ul>
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.sidebar-group-children .child-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.125rem;
|
||||
/* 0.5px */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-group-children .child-item:first-child::before {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* This selects the last child in a group */
|
||||
/* https://codepen.io/scmmishra/pen/yLmKNLW */
|
||||
.sidebar-group-children > .child-item:last-child::before,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::before {
|
||||
height: 20%;
|
||||
}
|
||||
|
||||
.sidebar-group-children > .child-item:last-child::after,
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
bottom: calc(50% - 2px);
|
||||
border-bottom-width: 0.125rem;
|
||||
border-left-width: 0.125rem;
|
||||
border-right-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 0 4px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
|
||||
#app[dir='rtl']
|
||||
.sidebar-group-children
|
||||
> *:last-child
|
||||
> *:last-child
|
||||
> .child-item:last-child::after {
|
||||
right: 0;
|
||||
border-bottom-width: 0.125rem;
|
||||
border-right-width: 0.125rem;
|
||||
border-left-width: 0px;
|
||||
border-top-width: 0px;
|
||||
border-radius: 0 0 4px 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="py-1 pl-3 text-n-slate-10 border rounded-lg border-dashed text-center border-n-alpha-2 text-xs h-8 grid place-content-center select-none pointer-events-none"
|
||||
>
|
||||
<slot>{{ t('SIDEBAR.NO_ITEMS') }}</slot>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
to: { type: [Object, String], default: '' },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object], default: '' },
|
||||
expandable: { type: Boolean, default: false },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isActive: { type: Boolean, default: false },
|
||||
hasActiveChild: { type: Boolean, default: false },
|
||||
getterKeys: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const showBadge = useMapGetter(props.getterKeys.badge);
|
||||
const dynamicCount = useMapGetter(props.getterKeys.count);
|
||||
const count = computed(() =>
|
||||
dynamicCount.value > 99 ? '99+' : dynamicCount.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
|
||||
role="button"
|
||||
draggable="false"
|
||||
:to="to"
|
||||
:title="label"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
||||
'text-n-slate-12 font-medium': hasActiveChild,
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||
}"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<div v-if="icon" class="relative flex items-center gap-2">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span
|
||||
v-if="showBadge"
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{
|
||||
'text-body-main': !isActive,
|
||||
'font-medium text-sm': isActive || hasActiveChild,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="expandable"
|
||||
v-show="isExpanded"
|
||||
class="i-lucide-chevron-up size-3"
|
||||
@click.stop="emit('toggle')"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { isVNode, computed } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
to: { type: [String, Object], required: true },
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="component"
|
||||
v-if="shouldRenderComponent"
|
||||
:label
|
||||
:icon
|
||||
:active
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const emit = defineEmits(['openNotificationPanel']);
|
||||
|
||||
const notificationMetadata = useMapGetter('notifications/getMeta');
|
||||
const route = useRoute();
|
||||
const unreadCount = computed(() => {
|
||||
if (!notificationMetadata.value.unreadCount) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return notificationMetadata.value.unreadCount < 100
|
||||
? `${notificationMetadata.value.unreadCount}`
|
||||
: '99+';
|
||||
});
|
||||
|
||||
function openNotificationPanel() {
|
||||
if (route.name !== 'notifications_index') {
|
||||
emit('openNotificationPanel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative"
|
||||
@click="openNotificationPanel"
|
||||
>
|
||||
<span class="i-lucide-bell size-4" />
|
||||
<span
|
||||
v-if="unreadCount"
|
||||
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSeparator,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
|
||||
|
||||
defineProps({
|
||||
isCollapsed: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'openKeyShortcutModal']);
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showChatSupport = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONTACT_CHATWOOT_SUPPORT_TEAM
|
||||
) && globalConfig.value.chatwootInboxToken
|
||||
);
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
show: showChatSupport.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: true,
|
||||
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => {
|
||||
emit('openKeyShortcutModal');
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: true,
|
||||
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
|
||||
icon: 'i-lucide-user-pen',
|
||||
link: { name: 'profile_settings_index' },
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: true,
|
||||
label: t('SIDEBAR_ITEMS.APPEARANCE'),
|
||||
icon: 'i-lucide-palette',
|
||||
click: () => {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'appearance_settings' });
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.DOCS'),
|
||||
icon: 'i-lucide-book',
|
||||
link: 'https://www.chatwoot.com/hc/user-guide/en',
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.CHANGELOG'),
|
||||
icon: 'i-lucide-scroll-text',
|
||||
link: 'https://www.chatwoot.com/changelog/',
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: currentUser.value.type === 'SuperAdmin',
|
||||
showOnCustomBrandedInstance: true,
|
||||
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
|
||||
icon: 'i-lucide-castle',
|
||||
link: '/super_admin',
|
||||
nativeLink: true,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
showOnCustomBrandedInstance: true,
|
||||
label: t('SIDEBAR_ITEMS.LOGOUT'),
|
||||
icon: 'i-lucide-power',
|
||||
click: Auth.logout,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const allowedMenuItems = computed(() => {
|
||||
return menuItems.value.filter(item => item.show);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer
|
||||
class="relative min-w-0"
|
||||
:class="isCollapsed ? 'w-auto' : 'w-full'"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center p-1 text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="[
|
||||
{ 'bg-n-alpha-1': isOpen },
|
||||
isCollapsed ? 'justify-center' : 'w-full',
|
||||
]"
|
||||
:title="isCollapsed ? currentUser.available_name : undefined"
|
||||
@click="toggle"
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:name="currentUser.available_name"
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-xs truncate text-n-slate-11">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
|
||||
<SidebarProfileMenuStatus />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
<CustomBrandPolicyWrapper
|
||||
:show-on-custom-branded-instance="item.showOnCustomBrandedInstance"
|
||||
>
|
||||
<DropdownItem v-if="item.show" v-bind="item" />
|
||||
</CustomBrandPolicyWrapper>
|
||||
</template>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed, h } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSection,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
const statusList = computed(() => {
|
||||
return [
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.ONLINE'),
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.BUSY'),
|
||||
t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUS.OFFLINE'),
|
||||
];
|
||||
});
|
||||
|
||||
const statusColors = ['bg-n-teal-9', 'bg-n-amber-9', 'bg-n-slate-9'];
|
||||
|
||||
const availabilityStatuses = computed(() => {
|
||||
return statusList.value.map((statusLabel, index) => ({
|
||||
label: statusLabel,
|
||||
value: AVAILABILITY_STATUS_KEYS[index],
|
||||
color: statusColors[index],
|
||||
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
|
||||
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
|
||||
}));
|
||||
});
|
||||
|
||||
const activeStatus = computed(() => {
|
||||
return availabilityStatuses.value.find(status => status.active);
|
||||
});
|
||||
|
||||
const autoOfflineToggle = computed({
|
||||
get: () => currentUserAutoOffline.value,
|
||||
set: autoOffline => {
|
||||
store.dispatch('updateAutoOffline', {
|
||||
accountId: currentAccountId.value,
|
||||
autoOffline,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
if (isImpersonating.value) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
account_id: currentAccountId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownSection class="[&>ul]:overflow-visible">
|
||||
<div class="grid gap-0">
|
||||
<DropdownItem preserve-open>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
|
||||
</div>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
size="sm"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="min-w-[96px]"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex gap-1 items-center flex-grow text-sm">
|
||||
<div class="p-1 flex-shrink-0">
|
||||
<div class="size-2 rounded-sm" :class="activeStatus.color" />
|
||||
</div>
|
||||
<span>{{ activeStatus.label }}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-32 z-20">
|
||||
<DropdownItem
|
||||
v-for="status in availabilityStatuses"
|
||||
:key="status.value"
|
||||
:label="status.label"
|
||||
:icon="status.icon"
|
||||
class="cursor-pointer"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
<Icon
|
||||
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
|
||||
icon="i-lucide-info"
|
||||
class="size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoOfflineToggle" />
|
||||
</DropdownItem>
|
||||
</div>
|
||||
</DropdownSection>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
return child.to && isAllowed(child.to);
|
||||
})
|
||||
);
|
||||
|
||||
const hasAccessibleItems = computed(() => {
|
||||
return accessibleItems.value.length > 0;
|
||||
});
|
||||
|
||||
const isScrollable = computed(() => {
|
||||
return accessibleItems.value.length > 7;
|
||||
});
|
||||
|
||||
const scrollEnd = ref(false);
|
||||
|
||||
// set scrollEnd to true when the scroll reaches the end
|
||||
useEventListener(scrollableContainer, 'scroll', () => {
|
||||
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
|
||||
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroupSeparator
|
||||
v-if="hasAccessibleItems"
|
||||
v-show="isExpanded"
|
||||
:label
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
>
|
||||
<SidebarGroupLeaf
|
||||
v-for="child in children"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:key="child.name"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="24"
|
||||
viewBox="0 0 16 24"
|
||||
fill="none"
|
||||
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 4L8 8L12 4"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 10L8 14L12 10"
|
||||
stroke="currentColor"
|
||||
opacity="0.75"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 16L8 20L12 16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.33333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { inject, provide, ref, computed } from 'vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const SidebarControl = Symbol('SidebarControl');
|
||||
|
||||
const DEFAULT_WIDTH = 200;
|
||||
const MIN_WIDTH = 56;
|
||||
const COLLAPSED_THRESHOLD = 160;
|
||||
const MAX_WIDTH = 320;
|
||||
|
||||
// Shared state for active popover (only one can be open at a time)
|
||||
const activePopover = ref(null);
|
||||
let globalCloseTimeout = null;
|
||||
|
||||
export function useSidebarResize() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const sidebarWidth = ref(uiSettings.value.sidebar_width || DEFAULT_WIDTH);
|
||||
const isCollapsed = computed(() => sidebarWidth.value < COLLAPSED_THRESHOLD);
|
||||
|
||||
const setSidebarWidth = width => {
|
||||
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
|
||||
};
|
||||
|
||||
const saveWidth = () => {
|
||||
updateUISettings({ sidebar_width: sidebarWidth.value });
|
||||
};
|
||||
|
||||
const snapToCollapsed = () => {
|
||||
sidebarWidth.value = MIN_WIDTH;
|
||||
updateUISettings({ sidebar_width: MIN_WIDTH });
|
||||
};
|
||||
|
||||
const snapToExpanded = () => {
|
||||
sidebarWidth.value = DEFAULT_WIDTH;
|
||||
updateUISettings({ sidebar_width: DEFAULT_WIDTH });
|
||||
};
|
||||
|
||||
return {
|
||||
sidebarWidth,
|
||||
isCollapsed,
|
||||
setSidebarWidth,
|
||||
saveWidth,
|
||||
snapToCollapsed,
|
||||
snapToExpanded,
|
||||
MIN_WIDTH,
|
||||
MAX_WIDTH,
|
||||
COLLAPSED_THRESHOLD,
|
||||
DEFAULT_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePopoverState() {
|
||||
const setActivePopover = name => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
activePopover.value = name;
|
||||
};
|
||||
|
||||
const closeActivePopover = () => {
|
||||
activePopover.value = null;
|
||||
};
|
||||
|
||||
const scheduleClose = (delay = 150) => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
globalCloseTimeout = setTimeout(() => {
|
||||
closeActivePopover();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const cancelClose = () => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
};
|
||||
|
||||
return {
|
||||
activePopover,
|
||||
setActivePopover,
|
||||
closeActivePopover,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarContext() {
|
||||
const context = inject(SidebarControl, null);
|
||||
if (context === null) {
|
||||
throw new Error(`Component is missing a parent <Sidebar /> component.`);
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const { shouldShow } = usePolicy();
|
||||
|
||||
const resolvePath = to => {
|
||||
if (to) return router.resolve(to)?.path || '/';
|
||||
return '/';
|
||||
};
|
||||
|
||||
// Helper to find route definition by name without resolving
|
||||
const findRouteByName = name => {
|
||||
const routes = router.getRoutes();
|
||||
return routes.find(route => route.name === name);
|
||||
};
|
||||
|
||||
const resolvePermissions = to => {
|
||||
if (!to) return [];
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.permissions ?? [];
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.permissions ?? [];
|
||||
};
|
||||
|
||||
const resolveFeatureFlag = to => {
|
||||
if (!to) return '';
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.featureFlag || '';
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.featureFlag || '';
|
||||
};
|
||||
|
||||
const resolveInstallationType = to => {
|
||||
if (!to) return [];
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.installationTypes || [];
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.installationTypes || [];
|
||||
};
|
||||
|
||||
const isAllowed = to => {
|
||||
const permissions = resolvePermissions(to);
|
||||
const featureFlag = resolveFeatureFlag(to);
|
||||
const installationType = resolveInstallationType(to);
|
||||
|
||||
return shouldShow(featureFlag, permissions, installationType);
|
||||
};
|
||||
|
||||
return {
|
||||
...context,
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
export function provideSidebarContext(context) {
|
||||
provide(SidebarControl, context);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
export function useSidebarKeyboardShortcuts(toggleShortcutModalFn) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isCurrentRouteSameAsNavigation = routeName => {
|
||||
return route.name === routeName;
|
||||
};
|
||||
|
||||
const navigateToRoute = routeName => {
|
||||
if (!isCurrentRouteSameAsNavigation(routeName)) {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'$mod+Slash': {
|
||||
action: () => toggleShortcutModalFn(true),
|
||||
},
|
||||
'$mod+Escape': {
|
||||
action: () => toggleShortcutModalFn(false),
|
||||
},
|
||||
'Alt+KeyC': {
|
||||
action: () => navigateToRoute('home'),
|
||||
},
|
||||
'Alt+KeyV': {
|
||||
action: () => navigateToRoute('contacts_dashboard'),
|
||||
},
|
||||
'Alt+KeyR': {
|
||||
action: () => navigateToRoute('account_overview_reports'),
|
||||
},
|
||||
'Alt+KeyS': {
|
||||
action: () => navigateToRoute('agent_list'),
|
||||
},
|
||||
};
|
||||
|
||||
return useKeyboardEvents(keyboardEvents);
|
||||
}
|
||||
Reference in New Issue
Block a user