Merge branch 'develop' into live-agent-reports-controller

This commit is contained in:
Pranav
2025-02-26 18:39:40 -08:00
834 changed files with 25762 additions and 9567 deletions
@@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
@@ -8,17 +8,17 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
isUpdating: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n();
const slots = useSlots();
@@ -45,9 +45,17 @@ const breadcrumbItems = computed(() => {
return items;
});
const isContactBlocked = computed(() => {
return props.selectedContact?.blocked;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
</script>
<template>
@@ -64,11 +72,29 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
<div class="flex items-center gap-2">
<Button
:label="
!isContactBlocked
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
@@ -50,6 +50,7 @@ const { t } = useI18n();
</div>
<ComboBox
id="inbox"
use-api-results
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
@@ -1,12 +1,12 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
const { featureFlag } = defineProps({
const props = defineProps({
currentPage: {
type: Number,
default: 1,
@@ -50,10 +50,10 @@ const { featureFlag } = defineProps({
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { isCloudFeatureEnabled } = useAccount();
const { shouldShowPaywall } = usePolicy();
const showPaywall = computed(() => {
return !isCloudFeatureEnabled(featureFlag);
return shouldShowPaywall(props.featureFlag);
});
const handleButtonClick = () => {
@@ -97,7 +97,7 @@ const handlePageChange = event => {
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="controls" />
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
@@ -13,34 +13,14 @@ const props = defineProps({
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
emptyState: { type: String, default: '' },
message: { type: String, default: '' },
hasError: { type: Boolean, default: false },
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search']);
@@ -54,6 +34,12 @@ const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
// For API search, don't filter options locally
if (props.useApiResults && search.value) {
return props.options;
}
// For local search, filter options based on search term
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm)
@@ -1,6 +1,21 @@
<script setup>
import { useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const attrs = useAttrs();
const globalConfig = useMapGetter('globalConfig/get');
</script>
<template>
<img
v-if="globalConfig.logoThumbnail"
v-bind="attrs"
:src="globalConfig.logoThumbnail"
/>
<svg
v-else
v-once
v-bind="attrs"
width="16"
height="16"
viewBox="0 0 16 16"
@@ -414,6 +414,11 @@ const avatarInfo = computed(() => {
};
});
const avatarTooltip = computed(() => {
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) {
return;
@@ -472,6 +477,7 @@ provideMessageContext({
>
<div
v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.right-end="avatarTooltip"
class="[grid-area:avatar] flex items-end"
>
<Avatar v-bind="avatarInfo" :size="24" />
@@ -10,6 +10,7 @@ defineProps({
iconBgColor: { type: String, default: 'bg-n-alpha-3' },
senderTranslationKey: { type: String, required: true },
content: { type: String, required: true },
title: { type: String, default: '' }, // Title can be any name, description, etc
action: {
type: Object,
required: true,
@@ -23,14 +24,14 @@ const { sender } = useMessageContext();
const { t } = useI18n();
const senderName = computed(() => {
return sender?.value.name;
return sender?.value?.name || '';
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
<div class="grid gap-4 min-w-64">
<div class="grid gap-3 z-20">
<div class="grid gap-3">
<div
class="size-8 rounded-lg grid place-content-center"
:class="iconBgColor"
@@ -48,6 +49,9 @@ const senderName = computed(() => {
}}
</div>
<slot>
<div v-if="title" class="truncate text-sm text-n-slate-12">
{{ title }}
</div>
<div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }}
</div>
@@ -11,7 +11,7 @@ import {
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
const { content, attachments } = useMessageContext();
const { attachments } = useMessageContext();
const $store = useStore();
const { t } = useI18n();
@@ -24,6 +24,12 @@ const phoneNumber = computed(() => {
return attachment.value.fallbackTitle;
});
const contactName = computed(() => {
const { meta } = attachment.value ?? {};
const { firstName, lastName } = meta ?? {};
return `${firstName ?? ''} ${lastName ?? ''}`.trim();
});
const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
});
@@ -32,13 +38,9 @@ const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, '');
});
const name = computed(() => {
return content.value;
});
function getContactObject() {
const contactItem = {
name: name.value,
name: contactName.value,
phone_number: `+${rawPhoneNumber.value}`,
};
return contactItem;
@@ -99,6 +101,7 @@ const action = computed(() => ({
icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-[#D6409F]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:title="contactName"
:content="phoneNumber"
:action="formattedPhoneNumber ? action : null"
/>
@@ -2,34 +2,30 @@
import { computed, ref } from 'vue';
import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue';
const { contentAttributes } = useMessageContext();
const { content, sender, id } = useMessageContext();
const { t } = useI18n();
const meetingData = computed(() => {
return useCamelCase(contentAttributes.value.data);
});
const isLoading = ref(false);
const dyteAuthToken = ref('');
const meetingLink = computed(() => {
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value);
return buildDyteURL(dyteAuthToken.value);
});
const joinTheCall = async () => {
isLoading.value = true;
try {
const { data: { authResponse: { authToken } = {} } = {} } =
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId);
dyteAuthToken.value = authToken;
const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
id.value
);
dyteAuthToken.value = token;
} catch (err) {
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally {
@@ -38,7 +34,7 @@ const joinTheCall = async () => {
};
const leaveTheRoom = () => {
this.dyteAuthToken = '';
dyteAuthToken.value = '';
};
const action = computed(() => ({
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
@@ -53,13 +49,18 @@ const action = computed(() => ({
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action"
>
<div v-if="!sender" class="text-sm truncate text-n-slate-12">
<!-- Added as a fallback, where the sender is not available (Deleted) -->
<!-- Will show the content, if senderName in BaseAttachment.vue is empty -->
{{ content }}
</div>
<div v-if="dyteAuthToken" class="video-call--container">
<iframe
:src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/>
<button
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
class="px-4 py-2 text-sm rounded-lg bg-n-solid-3 mt-3"
@click="leaveTheRoom"
>
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
@@ -1,6 +1,7 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
@@ -29,8 +30,15 @@ const isOutgoing = computed(() => {
});
const isIncoming = computed(() => !isOutgoing.value);
const textToShow = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
// Use TextContent as the default to fullHTML
const fullHTML = computed(() => {
return contentAttributes?.value?.email?.htmlContent?.full ?? content.value;
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
});
const unquotedHTML = computed(() => {
@@ -40,12 +48,6 @@ const unquotedHTML = computed(() => {
const hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
});
const textToShow = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
</script>
<template>
@@ -92,6 +94,11 @@ const textToShow = computed(() => {
<Letter
v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHTML"
:text="textToShow"
/>
@@ -99,6 +106,11 @@ const textToShow = computed(() => {
v-else
class-name="prose prose-bubble !max-w-none"
:html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow"
/>
</template>
@@ -25,16 +25,22 @@ const isPlaying = ref(false);
const isMuted = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`;
});
// There maybe a chance that the audioPlayer ref is not available
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
const formatTime = time => {
@@ -72,6 +78,16 @@ const playOrPause = () => {
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
playbackSpeed.value = 1;
audioPlayer.value.playbackRate = 1;
};
const changePlaybackSpeed = () => {
const speeds = [1, 1.5, 2];
const currentIndex = speeds.indexOf(playbackSpeed.value);
const nextIndex = (currentIndex + 1) % speeds.length;
playbackSpeed.value = speeds[nextIndex];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadAudio = async () => {
@@ -106,7 +122,7 @@ const downloadAudio = async () => {
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex items-center px-2">
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
@@ -116,6 +132,14 @@ const downloadAudio = async () => {
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
@@ -42,8 +42,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const globalConfig = useMapGetter('globalConfig/get');
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
@@ -242,24 +240,20 @@ const menuItems = computed(() => {
name: 'Captain',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
showOnlyOnCloud: true,
children: [
{
name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_assistants_index'),
},
{
name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_documents_index'),
},
{
name: 'Responses',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_responses_index'),
},
],
@@ -511,7 +505,6 @@ const menuItems = computed(() => {
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
showOnlyOnCloud: true,
to: accountScopedRoute('billing_settings_index'),
},
],
@@ -527,12 +520,7 @@ const menuItems = computed(() => {
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<img
v-if="globalConfig.logoThumbnail"
:src="globalConfig.logoThumbnail"
class="h-5 w-5"
/>
<Logo v-else />
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
@@ -24,7 +24,6 @@ const {
resolvePath,
resolvePermissions,
resolveFeatureFlag,
isOnChatwootCloud,
isAllowed,
} = useSidebarContext();
@@ -43,7 +42,6 @@ const hasChildren = computed(
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
// 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
@@ -166,7 +164,7 @@ onMounted(async () => {
:active-child="activeChild"
/>
<SidebarGroupLeaf
v-else
v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:active="activeChild?.name === child.name"
@@ -9,18 +9,10 @@ const props = defineProps({
to: { type: [String, Object], required: true },
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
showOnlyOnCloud: { type: Boolean, default: false },
component: { type: Function, default: null },
});
const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
useSidebarContext();
const allowedToShow = computed(() => {
if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
return true;
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
@@ -30,7 +22,6 @@ const shouldRenderComponent = computed(() => {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Policy
v-if="allowedToShow"
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
@@ -5,6 +5,7 @@ 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,
@@ -12,6 +13,7 @@ import {
DropdownSeparator,
DropdownItem,
} from 'next/dropdown-menu/base';
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
const emit = defineEmits(['close', 'openKeyShortcutModal']);
@@ -21,14 +23,28 @@ defineOptions({
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
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: !!globalConfig.value.chatwootInboxToken,
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy',
click: () => {
@@ -37,6 +53,7 @@ const menuItems = computed(() => {
},
{
show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard',
click: () => {
@@ -45,12 +62,14 @@ const menuItems = computed(() => {
},
{
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: () => {
@@ -60,6 +79,7 @@ const menuItems = computed(() => {
},
{
show: true,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
link: 'https://www.chatwoot.com/hc/user-guide/en',
@@ -68,6 +88,7 @@ const menuItems = computed(() => {
},
{
show: currentUser.value.type === 'SuperAdmin',
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle',
link: '/super_admin',
@@ -76,6 +97,7 @@ const menuItems = computed(() => {
},
{
show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-power',
click: Auth.logout,
@@ -122,7 +144,11 @@ const allowedMenuItems = computed(() => {
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
<DropdownItem v-if="item.show" v-bind="item" />
<CustomBrandPolicyWrapper
:show-on-custom-branded-instance="item.showOnCustomBrandedInstance"
>
<DropdownItem v-if="item.show" v-bind="item" />
</CustomBrandPolicyWrapper>
</template>
</DropdownBody>
</DropdownContainer>
@@ -14,12 +14,11 @@ const props = defineProps({
activeChild: { type: Object, default: undefined },
});
const { isAllowed, isOnChatwootCloud } = useSidebarContext();
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accessibleItems = computed(() =>
props.children.filter(child => {
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
return child.to && isAllowed(child.to);
})
);
@@ -1,5 +1,4 @@
import { inject, provide } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
@@ -12,9 +11,8 @@ export function useSidebarContext() {
}
const router = useRouter();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { checkFeatureAllowed, checkPermissions } = usePolicy();
const { shouldShow } = usePolicy();
const resolvePath = to => {
if (to) return router.resolve(to)?.path || '/';
@@ -31,11 +29,17 @@ export function useSidebarContext() {
return '';
};
const resolveInstallationType = to => {
if (to) return router.resolve(to)?.meta?.installationTypes || [];
return [];
};
const isAllowed = to => {
const permissions = resolvePermissions(to);
const featureFlag = resolveFeatureFlag(to);
const installationType = resolveInstallationType(to);
return checkPermissions(permissions) && checkFeatureAllowed(featureFlag);
return shouldShow(featureFlag, permissions, installationType);
};
return {
@@ -44,7 +48,6 @@ export function useSidebarContext() {
resolvePermissions,
resolveFeatureFlag,
isAllowed,
isOnChatwootCloud,
};
}