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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user