feat: Add frontend changes for Captain limits (#10749)
This PR introduces several improvements to the Captain AI dashboard section: - New billing page, with new colors, layout and meters for Captain usage - Updated the base paywall component to use new colors - Updated PageLayout.vue, it's more generic and can be used for other pages as well - Use flags to toggle empty state and loading state - Add prop for `featureFlag` to show the paywall slot based on feature enabled on account - Update `useAccount` to add a `isCloudFeatureEnabled` - **Removed feature flag checks from captain route definitions**, so the captain entry will always be visible on the sidebar - Add banner to Captain pages for the following cases - Responses usage is over 80% - Documents limit is fully exhausted ### Screenshots <details><summary>Free plan</summary> <p>   </p> </details> <details><summary>Paid plan</summary> <p>   </p> </details> --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -8,6 +10,10 @@ defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionPerms: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -16,7 +22,7 @@ defineProps({
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
@@ -39,7 +45,9 @@ defineProps({
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
<Policy :permissions="actionPerms">
|
||||
<slot name="actions" />
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
73
app/javascript/dashboard/components-next/banner/Banner.vue
Normal file
73
app/javascript/dashboard/components-next/banner/Banner.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'slate',
|
||||
validator: value =>
|
||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
||||
},
|
||||
actionLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
});
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
});
|
||||
|
||||
const triggerAction = () => {
|
||||
emit('action');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
|
||||
:class="[
|
||||
bannerClass,
|
||||
{
|
||||
'py-2 px-3': !actionLabel,
|
||||
'pl-3 p-2': actionLabel,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
|
||||
:class="buttonClass"
|
||||
@click="triggerAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
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';
|
||||
|
||||
defineProps({
|
||||
const { featureFlag } = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
@@ -19,10 +23,26 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonPolicy: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
featureFlag: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -30,6 +50,11 @@ defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const showPaywall = computed(() => {
|
||||
return !isCloudFeatureEnabled(featureFlag);
|
||||
});
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('click');
|
||||
@@ -52,16 +77,19 @@ const handlePageChange = event => {
|
||||
<slot name="headerTitle" />
|
||||
</span>
|
||||
<div
|
||||
v-if="!showPaywall"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +97,21 @@ 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="default" />
|
||||
<slot name="controls" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="showPaywall">
|
||||
<slot name="paywall" />
|
||||
</div>
|
||||
<div v-else-if="isEmpty">
|
||||
<slot name="emptyState" />
|
||||
</div>
|
||||
<slot v-else name="body" />
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -28,31 +29,41 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
|
||||
value: 'viewConnectedInboxes',
|
||||
action: 'viewConnectedInboxes',
|
||||
icon: 'i-lucide-link',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
|
||||
value: 'viewConnectedInboxes',
|
||||
action: 'viewConnectedInboxes',
|
||||
icon: 'i-lucide-link',
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
allOptions.push(
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
@@ -32,25 +33,33 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
allOptions.push({
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
});
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -76,8 +77,9 @@ const handleAction = ({ action, value }) => {
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@@ -93,7 +95,7 @@ const handleAction = ({ action, value }) => {
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -107,8 +108,9 @@ const handleDocumentableClick = () => {
|
||||
{{ question }}
|
||||
</span>
|
||||
<div v-if="!compact" class="flex items-center gap-2">
|
||||
<div
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
@@ -124,7 +126,7 @@ const handleDocumentableClick = () => {
|
||||
class="mt-1 ltr:right-0 rtl:right-0 top-full"
|
||||
@action="handleAssistantAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return currentUser.value.type === 'SuperAdmin';
|
||||
});
|
||||
const { accountId, isOnChatwootCloud } = useAccount();
|
||||
|
||||
const i18nKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
|
||||
);
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="CAPTAIN"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const { documentLimits, fetchLimits } = useCaptain();
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const showBanner = computed(() => {
|
||||
if (!documentLimits.value) return false;
|
||||
|
||||
const { currentAvailable } = documentLimits.value;
|
||||
return currentAvailable === 0;
|
||||
});
|
||||
|
||||
onMounted(fetchLimits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-show="showBanner"
|
||||
color="amber"
|
||||
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
|
||||
@action="openBilling"
|
||||
>
|
||||
{{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
@@ -15,6 +15,7 @@ const onClick = () => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const { responseLimits, fetchLimits } = useCaptain();
|
||||
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const showBanner = computed(() => {
|
||||
if (!responseLimits.value) return false;
|
||||
|
||||
const { consumed, totalCount } = responseLimits.value;
|
||||
if (!consumed || !totalCount) return false;
|
||||
|
||||
return consumed / totalCount > 0.8;
|
||||
});
|
||||
|
||||
onMounted(fetchLimits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-show="showBanner"
|
||||
color="amber"
|
||||
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
|
||||
@action="openBilling"
|
||||
>
|
||||
{{ $t('CAPTAIN.BANNER.RESPONSES') }}
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -171,20 +171,24 @@ 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'),
|
||||
},
|
||||
],
|
||||
@@ -455,6 +459,7 @@ const menuItems = computed(() => {
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('billing_settings_index'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isOnChatwootCloud,
|
||||
isAllowed,
|
||||
} = useSidebarContext();
|
||||
|
||||
@@ -41,6 +42,7 @@ 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
|
||||
|
||||
@@ -9,18 +9,28 @@ 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 } = useSidebarContext();
|
||||
const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
|
||||
useSidebarContext();
|
||||
|
||||
const allowedToShow = computed(() => {
|
||||
if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
v-if="allowedToShow"
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
|
||||
@@ -15,11 +15,14 @@ const props = defineProps({
|
||||
activeChild: { type: Object, default: undefined },
|
||||
});
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const { isAllowed, isOnChatwootCloud } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => isAllowed(child.to))
|
||||
props.children.filter(child => {
|
||||
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
return child.to && isAllowed(child.to);
|
||||
})
|
||||
);
|
||||
|
||||
const hasAccessibleItems = computed(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -11,6 +12,8 @@ export function useSidebarContext() {
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
|
||||
const { checkFeatureAllowed, checkPermissions } = usePolicy();
|
||||
|
||||
const resolvePath = to => {
|
||||
@@ -41,6 +44,7 @@ export function useSidebarContext() {
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
isOnChatwootCloud,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user