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>

![CleanShot 2025-01-22 at 18 37
11@2x](https://github.com/user-attachments/assets/17d3ddba-9095-4e81-9b6f-45b5f69e6a3f)
![CleanShot 2025-01-22 at 18 37
04@2x](https://github.com/user-attachments/assets/df9bb0a6-085f-45da-97d4-74cbcc33fc7e)


</p>
</details> 

<details><summary>Paid plan</summary>
<p>

![CleanShot 2025-01-22 at 18 36
45@2x](https://github.com/user-attachments/assets/a7ccf9d4-143b-49e4-8149-83c7a7985023)

![CleanShot 2025-01-22 at 20 23
57@2x](https://github.com/user-attachments/assets/c6ce35ba-e537-486d-85c8-4cc2d4e76438)


</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:
Shivam Mishra
2025-01-24 22:51:09 +05:30
committed by GitHub
parent b429ce0ad5
commit ef7bf66476
41 changed files with 920 additions and 369 deletions

View File

@@ -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>

View 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>

View File

@@ -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">

View File

@@ -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));

View File

@@ -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));

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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'),
},
],

View File

@@ -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

View File

@@ -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"

View File

@@ -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(() => {

View File

@@ -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,
};
}

View File

@@ -13,10 +13,14 @@ export function useAccount() {
*/
const route = useRoute();
const getAccountFn = useMapGetter('accounts/getAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const accountId = computed(() => {
return Number(route.params.accountId);
});
const currentAccount = computed(() => getAccountFn.value(accountId.value));
/**
@@ -28,6 +32,10 @@ export function useAccount() {
return `/app/accounts/${accountId.value}/${url}`;
};
const isCloudFeatureEnabled = feature => {
return isFeatureEnabledonAccount.value(currentAccount.value.id, feature);
};
const accountScopedRoute = (name, params, query) => {
return {
name,
@@ -42,5 +50,7 @@ export function useAccount() {
currentAccount,
accountScopedUrl,
accountScopedRoute,
isCloudFeatureEnabled,
isOnChatwootCloud,
};
}

View File

@@ -0,0 +1,46 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
const documentLimits = computed(() => {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
const responseLimits = computed(() => {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
const fetchLimits = () => {
store.dispatch('accounts/limits');
};
return {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
};
}

View File

@@ -18,6 +18,7 @@ const FEATURE_HELP_URLS = {
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
};
export function getHelpUrlForFeature(featureName) {

View File

@@ -309,6 +309,22 @@
"USE": "Use this",
"RESET": "Reset"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
"AVAILABLE_ON": "Captain is not available on the free plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"BANNER": {
"RESPONSES": "You've used over 80% of your response limit. To continue using Captain AI, please upgrade.",
"DOCUMENTS": "Document limit reached. Upgrade to continue using Captain AI."
},
"FORM": {
"CANCEL": "Cancel",
"CREATE": "Create",
@@ -364,7 +380,7 @@
},
"EMPTY_STATE": {
"TITLE": "No assistants available",
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations. Click the button below to get started."
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations."
}
},
"DOCUMENTS": {
@@ -406,13 +422,13 @@
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant. Click the button below to get started."
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant."
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE" : {
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"DELETE": {
@@ -422,7 +438,7 @@
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
},
"FILTER" :{
"FILTER": {
"ASSISTANT": "Assistant: {selected}",
"STATUS": "Status: {selected}",
"ALL_ASSISTANTS": "All"
@@ -470,7 +486,7 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually. Click the button below to create your first FAQ."
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually."
}
},
"INBOXES": {
@@ -501,7 +517,7 @@
},
"EMPTY_STATE": {
"TITLE": "No Connected Inboxes",
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you. Click the button below to set it up now."
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you."
}
}
}

View File

@@ -265,7 +265,7 @@
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES" : "FAQs",
"CAPTAIN_RESPONSES": "FAQs",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -327,15 +327,27 @@
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses"
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on"
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
"TITLE": "Manage your subscription",
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",

View File

@@ -1,13 +1,15 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
@@ -73,29 +75,37 @@ onMounted(() => store.dispatch('captainAssistants/get'));
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:button-label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
:button-policy="['administrator']"
:show-pagination-footer="false"
:is-fetching="isFetching"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:is-empty="!assistants.length"
@click="handleCreate"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="assistants.length" class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
<template #emptyState>
<AssistantPageEmptyState @click="handleCreate" />
</template>
<AssistantPageEmptyState v-else @click="handleCreate" />
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedAssistant"

View File

@@ -6,11 +6,11 @@ import {
useStoreGetters,
} from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue';
@@ -67,19 +67,16 @@ onMounted(() =>
</script>
<template>
<div
v-if="isFetchingAssistant"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<PageLayout
v-else
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
:button-policy="['administrator']"
:is-fetching="isFetchingAssistant || isFetching"
:is-empty="!captainInboxes.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:show-pagination-footer="false"
@click="handleCreate"
>
<template #headerTitle>
<template v-if="!isFetchingAssistant" #headerTitle>
<div class="flex flex-row items-center gap-4">
<BackButton compact />
<span class="flex items-center gap-1 text-lg">
@@ -89,23 +86,22 @@ onMounted(() =>
</span>
</div>
</template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="captainInboxes.length" class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
<InboxPageEmptyState v-else @click="handleCreate" />
<template #emptyState>
<InboxPageEmptyState @click="handleCreate" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedInbox"

View File

@@ -1,4 +1,4 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
@@ -11,7 +11,6 @@ export const routes = [
component: AssistantIndex,
name: 'captain_assistants_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -22,7 +21,6 @@ export const routes = [
component: AssistantInboxesIndex,
name: 'captain_assistants_inboxes_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -31,7 +29,6 @@ export const routes = [
component: DocumentsIndex,
name: 'captain_documents_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -40,7 +37,6 @@ export const routes = [
component: ResponsesIndex,
name: 'captain_responses_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},

View File

@@ -1,15 +1,17 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
const store = useStore();
@@ -103,38 +105,49 @@ onMounted(() => {
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:button-label="$t('CAPTAIN.DOCUMENTS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="documentsMeta.totalCount"
:current-page="documentsMeta.page"
:show-pagination-footer="!isFetching && !!documents.length"
:is-fetching="isFetching"
:is-empty="!documents.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<div v-if="shouldShowAssistantSelector" class="mb-4 -mt-3 flex gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="documents.length" class="flex flex-col gap-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
:key="doc.id"
:name="doc.name || doc.external_link"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
@action="handleAction"
/>
</div>
<template #emptyState>
<DocumentPageEmptyState @click="handleCreateDocument" />
</template>
<DocumentPageEmptyState v-else @click="handleCreateDocument" />
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<div v-if="shouldShowAssistantSelector" class="mb-4 -mt-3 flex gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
:key="doc.id"
:name="doc.name || doc.external_link"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
@action="handleAction"
/>
</div>
</template>
<RelatedResponses
v-if="showRelatedResponses"

View File

@@ -5,16 +5,18 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const store = useStore();
@@ -156,61 +158,71 @@ onMounted(() => {
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!responses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:show-pagination-footer="!isFetching && !!responses.length"
@update:current-page="onPageChange"
@click="handleCreate"
>
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
</template>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
@action="handleAction"
@navigate="handleNavigationAction"
/>
</OnClickOutside>
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="responses.length" class="flex flex-col gap-4">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
@action="handleAction"
@navigate="handleNavigationAction"
/>
</div>
<ResponsePageEmptyState v-else @click="handleCreate" />
</div>
</template>
<DeleteDialog
v-if="selectedResponse"

View File

@@ -30,7 +30,7 @@ defineProps({
</slot>
<p
v-else-if="noRecordsFound"
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
class="flex-1 py-20 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
>
{{ noRecordsMessage }}
</p>

View File

@@ -1,123 +1,181 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { mapGetters } from 'vuex';
<script setup>
import { computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import BillingItem from './components/BillingItem.vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
export default {
components: { BillingItem },
setup() {
const { accountId } = useAccount();
const { formatMessage } = useMessageFormatter();
return {
accountId,
formatMessage,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
}),
currentAccount() {
return this.getAccount(this.accountId) || {};
},
customAttributes() {
return this.currentAccount.custom_attributes || {};
},
hasABillingPlan() {
return !!this.planName;
},
planName() {
return this.customAttributes.plan_name || '';
},
subscribedQuantity() {
return this.customAttributes.subscribed_quantity || 0;
},
},
mounted() {
this.fetchAccountDetails();
},
methods: {
async fetchAccountDetails() {
if (!this.hasABillingPlan) {
this.$store.dispatch('accounts/subscription');
}
},
onClickBillingPortal() {
this.$store.dispatch('accounts/checkout');
},
onToggleChatWindow() {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
},
},
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
const { currentAccount } = useAccount();
const {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
/**
* Computed property for plan name
* @returns {string|undefined}
*/
const planName = computed(() => {
return customAttributes.value.plan_name;
});
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
*/
const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
// return date as 12 Jan, 2034
return format(endDate, 'dd MMM, yyyy');
});
/**
* Computed property indicating if user has a billing plan
* @returns {boolean}
*/
const hasABillingPlan = computed(() => {
return !!planName.value;
});
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
store.dispatch('accounts/subscription');
fetchLimits();
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
const onToggleChatWindow = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
onMounted(fetchAccountDetails);
</script>
<template>
<div class="flex-1 p-6 overflow-auto dark:bg-slate-900">
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div v-else-if="!hasABillingPlan">
<p>{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}</p>
</div>
<div v-else class="w-full">
<div class="current-plan--details">
<h6>{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}</h6>
<div
v-dompurify-html="
formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
/>
</div>
<BillingItem
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
:button-label="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT')"
@open="onClickBillingPortal"
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!hasABillingPlan"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS.TITLE')"
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
/>
<BillingItem
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
:button-label="$t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT')"
button-icon="chat-multiple"
@open="onToggleChatWindow"
/>
</div>
</div>
</template>
<template #body>
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm solid blue @click="onClickBillingPortal">
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
</ButtonV4>
</template>
<div
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
>
<DetailItem
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
:value="planName"
/>
<DetailItem
v-if="subscribedQuantity"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
:value="subscribedQuantity"
/>
<DetailItem
v-if="subscriptionRenewsOn"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
</div>
</BillingCard>
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm solid slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
v-bind="responseLimits"
/>
</div>
<div v-if="captainLimits && documentLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
v-bind="documentLimits"
/>
</div>
</BillingCard>
<BillingCard
v-else
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
>
<template #action>
<ButtonV4 sm solid slate @click="onClickBillingPortal">
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
</ButtonV4>
</template>
</BillingCard>
<BillingHeader
class="px-1 mt-5"
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
>
<ButtonV4
sm
solid
slate
icon="i-lucide-life-buoy"
@open="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
</BillingHeader>
</section>
</template>
</SettingsLayout>
</template>
<style lang="scss">
.manage-subscription {
@apply bg-white dark:bg-slate-800 flex justify-between mb-2 py-6 px-4 items-center rounded-md border border-solid border-slate-75 dark:border-slate-700;
}
.current-plan--details {
@apply border-b border-solid border-slate-75 dark:border-slate-800 mb-4 pb-4;
h6 {
@apply text-slate-800 dark:text-slate-100;
}
p {
@apply text-slate-600 dark:text-slate-200;
}
}
.manage-subscription {
.manage-subscription--description {
@apply mb-0 text-slate-600 dark:text-slate-200;
}
h6 {
@apply text-slate-800 dark:text-slate-100;
}
}
</style>

View File

@@ -1,5 +1,5 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
@@ -9,7 +9,7 @@ export default {
meta: {
permissions: ['administrator'],
},
component: SettingsContent,
component: SettingsWrapper,
props: {
headerTitle: 'BILLING_SETTINGS.TITLE',
icon: 'credit-card-person',

View File

@@ -0,0 +1,25 @@
<script setup>
import BillingHeader from './BillingHeader.vue';
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="rounded-xl shadow-sm border border-n-weak bg-n-solid-3 py-5 space-y-5"
>
<BillingHeader :title :description class="px-5">
<slot name="action" />
</BillingHeader>
<slot />
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
</script>
<template>
<div class="grid grid-cols-[1fr_200px] gap-5">
<div>
<span class="text-base font-medium text-n-slate-12">
{{ title }}
</span>
<p class="text-sm mt-1 text-n-slate-11">
{{ description }}
</p>
</div>
<slot />
</div>
</template>

View File

@@ -1,40 +0,0 @@
<script>
export default {
props: {
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
},
emits: ['open'],
};
</script>
<template>
<div class="manage-subscription">
<div>
<h6>{{ title }}</h6>
<p class="manage-subscription--description">
{{ description }}
</p>
</div>
<div>
<woot-button variant="smooth" :icon="buttonIcon" @click="$emit('open')">
{{ buttonLabel }}
</woot-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: {
type: String,
required: true,
},
consumed: {
type: Number,
required: true,
},
totalCount: {
type: Number,
required: true,
},
});
const percent = computed(() =>
Math.round((props.consumed / props.totalCount) * 100)
);
const colorClass = computed(() => {
if (percent.value < 50) {
return 'bg-n-teal-10';
}
if (percent.value < 80) {
return 'bg-n-amber-10';
}
return 'bg-n-ruby-10';
});
</script>
<template>
<div
class="flex gap-5 items-center justify-between text-xs uppercase text-n-slate-10"
>
<div class="font-medium tracking-wider">
{{ title }}
</div>
<div class="tabular-nums">{{ consumed }} / {{ totalCount }}</div>
</div>
<div class="rounded-full overflow-hidden h-2 w-full bg-n-container mt-2">
<div class="h-2" :class="colorClass" :style="{ width: `${percent}%` }" />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
});
</script>
<template>
<div class="px-5">
<span class="text-n-slate-11 text-xs">
{{ label }}
</span>
<div class="mt-2 text-xl font-medium text-n-slate-12">
{{ value }}
</div>
</div>
</template>

View File

@@ -1,4 +1,7 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import ButtonV4 from 'next/button/Button.vue';
defineProps({
featurePrefix: {
type: String,
@@ -23,56 +26,44 @@ const emit = defineEmits(['upgrade']);
<template>
<div
class="flex flex-col max-w-md px-6 py-6 bg-white border shadow dark:bg-slate-800 rounded-xl border-slate-100 dark:border-slate-900"
class="flex flex-col max-w-md px-6 py-6 border shadow bg-n-solid-1 rounded-xl border-n-weak"
>
<div class="flex items-center w-full gap-2 mb-4">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-woot-75/70 dark:bg-woot-800/40"
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<fluent-icon
size="14"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
icon="lock-closed"
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-slate-900 dark:text-white">
<span class="text-base font-medium text-n-slate-12">
{{ $t(`${featurePrefix}.PAYWALL.TITLE`) }}
</span>
</div>
<p
class="text-sm font-normal"
class="text-sm font-normal text-n-slate-11"
v-html="$t(`${featurePrefix}.${i18nKey}.AVAILABLE_ON`)"
/>
<p class="text-sm font-normal">
<p class="text-sm font-normal text-n-slate-11">
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
</span>
</p>
<template v-if="isOnChatwootCloud">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
@click="emit('upgrade')"
>
<ButtonV4 blue solid md @click="emit('upgrade')">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</woot-button>
<span class="mt-2 text-xs tracking-tight text-center">
</ButtonV4>
<span class="mt-2 text-xs tracking-tight text-center text-n-slate-11">
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
</span>
</template>
<template v-else-if="isSuperAdmin">
<a href="/super_admin" class="block w-full">
<woot-button
color-scheme="primary"
class="w-full mt-2 text-center rounded-xl"
size="expanded"
is-expanded
>
<ButtonV4 solid blue md class="w-full">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</woot-button>
</ButtonV4>
</a>
</template>
</div>

View File

@@ -15,7 +15,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
def limits
limits = {
'conversation' => {},
'non_web_inboxes' => {}
'non_web_inboxes' => {},
'captain' => @account.usage_limits[:captain]
}
if default_plan?(@account)

View File

@@ -66,7 +66,7 @@ class Enterprise::Billing::HandleStripeEventService
end
def features_to_update
%w[help_center campaigns team_management channel_twitter channel_facebook channel_email]
%w[help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
end
def subscription

View File

@@ -173,6 +173,18 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
'id' => account.id,
'limits' => {
'conversation' => {},
'captain' => {
'documents' => {
'consumed' => 0,
'current_available' => ChatwootApp.max_limit,
'total_count' => ChatwootApp.max_limit
},
'responses' => {
'consumed' => 0,
'current_available' => ChatwootApp.max_limit,
'total_count' => ChatwootApp.max_limit
}
},
'non_web_inboxes' => {}
}
}