Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma a1a31e50ad add UI for stripe_v2 2025-12-01 20:01:59 +05:30
32 changed files with 1673 additions and 1085 deletions
@@ -92,14 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(
:auto_resolve_after,
:auto_resolve_message,
:auto_resolve_ignore_waiting,
:audio_transcriptions,
:auto_resolve_label,
conversation_required_attributes: []
)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled
@@ -6,6 +6,7 @@ class EnterpriseAccountAPI extends ApiClient {
super('', { accountScoped: true, enterprise: true });
}
// V1 endpoints
checkout() {
return axios.post(`${this.url}checkout`);
}
@@ -23,6 +24,49 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
// V2 Billing endpoints
get v2BillingUrl() {
const accountId = this.accountIdFromRoute;
return `/enterprise/api/v2/accounts/${accountId}/billing`;
}
getCreditGrants() {
return axios.get(`${this.v2BillingUrl}/credit_grants`);
}
getPricingPlans() {
return axios.get(`${this.v2BillingUrl}/pricing_plans`);
}
getTopupOptions() {
return axios.get(`${this.v2BillingUrl}/topup_options`);
}
topupCredits(credits) {
return axios.post(`${this.v2BillingUrl}/topup`, { credits });
}
subscribeToPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/subscribe`, {
pricing_plan_id: pricingPlanId,
quantity,
});
}
cancelSubscription(reason = null, feedback = null) {
return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
reason,
feedback,
});
}
changePricingPlan(pricingPlanId, quantity) {
return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
pricing_plan_id: pricingPlanId,
quantity,
});
}
}
export default new EnterpriseAccountAPI();
@@ -1,11 +0,0 @@
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: { type: String, default: 'resolution' },
});
const colorClass = computed(() => {
const colorMap = {
'pre-chat': 'blue',
resolution: 'teal',
};
const color = colorMap[props.type] || colorMap.resolution;
return `text-n-${color}-11`;
});
const iconName = computed(() => {
const iconMap = {
'pre-chat': 'i-lucide-message-circle',
resolution: 'i-lucide-circle-check-big',
};
return iconMap[props.type] || iconMap.resolution;
});
const displayLabel = computed(() => {
const labelMap = {
'pre-chat': 'Pre-chat',
resolution: 'Resolution',
};
return labelMap[props.type] || labelMap.resolution;
});
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="iconName" class="size-4" :class="colorClass" />
<span class="text-xs" :class="colorClass">{{ displayLabel }}</span>
</div>
</template>
@@ -1,80 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/ConversationWorkflow/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-white rounded-2xl border border-n-weak dark:bg-n-solid-1"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
<div class="w-px h-3 bg-n-weak" />
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.label"
:type="badge.type"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -1,55 +0,0 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>
@@ -1,145 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
});
const emit = defineEmits(['click']);
const { t } = useI18n();
const { currentAccount, updateAccount } = useAccount();
const showDropdown = ref(false);
const isSaving = ref(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() =>
allAttributeOptions.value.filter(
attribute => !selectedAttributeKeys.value.includes(attribute.value)
)
);
const conversationRequiredAttributes = computed(() =>
selectedAttributeKeys.value
.map(key =>
allAttributeOptions.value.find(attribute => attribute.value === key)
)
.filter(Boolean)
);
const handleAddAttributesClick = event => {
event.stopPropagation();
showDropdown.value = !showDropdown.value;
};
const saveRequiredAttributes = async keys => {
try {
isSaving.value = true;
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
isSaving.value = false;
showDropdown.value = false;
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
showDropdown.value = false;
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<CardLayout
class="[&>div]:px-0 [&>div]:py-0 [&>div]:gap-0 [&>div]:divide-y [&>div]:divide-n-weak"
@click="handleClick"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">{{ title }}</h3>
<div v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">{{ description }}</p>
</div>
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</CardLayout>
</template>
@@ -1,165 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = ref({});
const close = () => {
dialogRef.value?.close();
};
const getPlaceholder = type => {
const placeholders = {
text: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'
),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'
),
date: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'
),
list: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'
),
};
return placeholders[type] || '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues.value?.[attribute.value];
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const open = (attributes = [], initialValues = {}) => {
visibleAttributes.value = attributes;
formValues.value = attributes.reduce((acc, attribute) => {
const presetValue = initialValues[attribute.value];
acc[attribute.value] =
presetValue !== undefined && presetValue !== null ? presetValue : '';
return acc;
}, {});
dialogRef.value?.open();
};
const handleConfirm = () => {
emit('submit', { ...formValues.value });
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:show-confirm-button="isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === 'text'">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder('text')"
/>
</template>
<template v-else-if="attribute.type === 'number'">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder('number')"
/>
</template>
<template v-else-if="attribute.type === 'link'">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder('link')"
/>
</template>
<template v-else-if="attribute.type === 'date'">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder('date')"
/>
</template>
<template v-else-if="attribute.type === 'list'">
<ComboBox
v-model="formValues[attribute.value]"
:options="
(
attribute.attribute_values ||
attribute.attributeValues ||
[]
).map(option => ({
value: option,
label: option,
}))
"
:placeholder="getPlaceholder('list')"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === 'checkbox'">
<ChoiceToggle
v-model="formValues[attribute.value]"
:yes-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.YES')
"
:no-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.NO')
"
/>
</template>
</div>
</div>
</Dialog>
</template>
@@ -1,52 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
yesLabel: {
type: String,
default: 'Yes',
},
noLabel: {
type: String,
default: 'No',
},
});
const emit = defineEmits(['update:modelValue']);
const options = computed(() => [
{ label: props.yesLabel, value: 'yes' },
{ label: props.noLabel, value: 'no' },
]);
const handleSelect = value => {
emit('update:modelValue', value);
};
</script>
<template>
<div
class="flex gap-4 items-center px-4 py-2.5 w-full bg-white rounded-lg divide-x transition-colors outline outline-1 outline-n-weak dark:bg-n-solid-1 hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
>
<div
v-for="option in options"
:key="option.value"
class="flex flex-1 gap-2 justify-center items-center"
>
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
<input
type="radio"
:value="option.value"
:checked="modelValue === option.value"
class="size-4 accent-n-blue-9 text-n-blue-9"
@change="handleSelect(option.value)"
/>
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
</label>
</div>
</div>
</template>
@@ -520,7 +520,7 @@ const menuItems = computed(() => {
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-repeat',
icon: 'i-lucide-workflow',
to: accountScopedRoute('automation_list'),
},
{
@@ -565,12 +565,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: ' Conversation Workflow',
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('conversation_workflow_index'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
@@ -652,7 +646,7 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['change', 'tabChanged']);
const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab);
@@ -51,7 +51,6 @@ onMounted(() => {
});
const selectTab = index => {
emit('change', index);
emit('tabChanged', props.tabs[index]);
};
@@ -3,14 +3,9 @@ import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAccount } from 'dashboard/composables/useAccount';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -22,19 +17,13 @@ import {
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const { currentAccount } = useAccount();
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const resolveAttributesModalRef = ref(null);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
@@ -63,103 +52,31 @@ const showOpenButton = computed(() => {
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const requiredAttributes = computed(() =>
requiredAttributeKeys.value.map(key => {
const definition = conversationAttributes.value?.find(
attribute => attribute.attributeKey === key
);
if (!definition) {
return {
value: key,
label: key,
type: 'text',
attribute_values: [],
};
}
return {
...definition,
value: definition.attributeKey,
label: definition.attributeDisplayName,
type: definition.attributeDisplayType,
};
})
);
const getMissingRequiredAttributes = () => {
if (!currentChat.value?.id) return [];
const existingValues = currentChat.value.custom_attributes || {};
return requiredAttributes.value.filter(attribute => {
const value = existingValues?.[attribute.value];
return value === undefined || value === null || String(value) === '';
});
};
const toggleStatusInternal = async (status, snoozedUntil = null) => {
closeDropdown();
isLoading.value = true;
try {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
});
useAlert(t('CONVERSATION.CHANGE_STATUS'));
} finally {
isLoading.value = false;
}
};
const resolveWithAttributes = async customAttributes => {
if (!currentChat.value?.id) return;
try {
isLoading.value = true;
if (requiredAttributeKeys.value.length) {
await store.dispatch('updateCustomAttributes', {
conversationId: currentChat.value.id,
customAttributes,
});
}
await toggleStatusInternal(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
isLoading.value = false;
}
};
const openResolveModal = () => {
closeDropdown();
if (!currentChat.value?.id) return;
const missing = getMissingRequiredAttributes();
if (!missing.length) {
resolveWithAttributes(currentChat.value.custom_attributes || {});
return;
}
resolveAttributesModalRef.value?.open(
missing,
currentChat.value.custom_attributes || {}
);
};
const handleModalSubmit = async values => {
if (!currentChat.value?.id) return;
resolveAttributesModalRef.value?.close();
await resolveWithAttributes({
...(currentChat.value.custom_attributes || {}),
...values,
});
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
@@ -180,7 +97,7 @@ const onCmdOpenConversation = () => {
};
const onCmdResolveConversation = () => {
openResolveModal();
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
};
const keyboardEvents = {
@@ -189,11 +106,21 @@ const keyboardEvents = {
allowOnFocusedInput: true,
},
'Alt+KeyE': {
action: () => openResolveModal(),
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
},
},
'$mod+Alt+KeyE': {
action: event => {
openResolveModal();
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
},
@@ -206,13 +133,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div class="flex relative justify-end items-center resolve-actions">
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleModalSubmit"
/>
<div class="relative flex items-center justify-end resolve-actions">
<ButtonGroup
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -1,43 +1,18 @@
import { computed, ref, unref } from 'vue';
import { ref, unref } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
export function useBulkActions() {
const store = useStore();
const { t } = useI18n();
const { currentAccount } = useAccount();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const allConversations = useMapGetter('getAllConversations');
const selectedInboxes = ref([]);
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const conversationsById = computed(() =>
(allConversations.value || []).reduce((acc, conversation) => {
acc[conversation.id] = conversation;
return acc;
}, {})
);
const hasAllRequiredValues = conversation => {
if (!conversation) return false;
const attrs = conversation.custom_attributes || {};
return requiredAttributeKeys.value.every(key => {
const value = attrs?.[key];
return (
value !== undefined && value !== null && String(value).trim() !== ''
);
});
};
function selectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
selectedInboxes.value = [...selectedInboxes.value, inboxId];
@@ -141,58 +116,17 @@ export function useBulkActions() {
}
async function onUpdateConversations(status, snoozedUntil) {
const selectedIds = selectedConversations.value;
const requiresCheck =
status === 'resolved' && requiredAttributeKeys.value.length > 0;
const resolveCandidates = requiresCheck
? selectedIds.reduce(
(result, id) => {
const conversation = conversationsById.value[id];
if (hasAllRequiredValues(conversation)) {
result.resolvable.push(id);
} else {
result.blocked.push(id);
}
return result;
},
{ resolvable: [], blocked: [] }
)
: { resolvable: selectedIds, blocked: [] };
if (!resolveCandidates.resolvable.length) {
if (requiresCheck) {
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
return;
}
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: resolveCandidates.resolvable,
ids: selectedConversations.value,
fields: {
status,
},
snoozed_until: snoozedUntil,
});
// Update selection to keep only blocked conversations, if any.
store.dispatch('bulkActions/clearSelectedConversationIds');
selectedInboxes.value = [];
if (resolveCandidates.blocked.length) {
store.dispatch('bulkActions/setSelectedConversationIds', [
...resolveCandidates.blocked,
]);
selectedInboxes.value = resolveCandidates.blocked
.map(id => conversationsById.value[id]?.inbox_id)
.filter(Boolean);
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
}
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
@@ -20,8 +20,7 @@
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again.",
"REQUIRED_ATTRIBUTES_MISSING": "Some conversations need required attributes before resolving and were skipped."
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
@@ -377,12 +377,13 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"BILLING_PORTAL": "Billing Portal",
"VIEW_ALL_PLANS": "View all plans",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
@@ -395,6 +396,25 @@
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"SUBSCRIPTION": {
"TITLE": "Subscription",
"DESCRIPTION": "Manage your current plan, seats, and billing cycle.",
"CURRENT_PLAN": "Current plan",
"NUMBER_OF_SEATS": "Number of seats",
"RENEWS_ON": "Renews on",
"ENDS_ON": "Ends on",
"CANCELLED_ON": "Cancelled on",
"CHANGE_PLAN": "Change",
"CHANGE_SEATS": "Change",
"CANCEL_SUBSCRIPTION": "Cancel"
},
"CAPTAIN_AI": {
"TITLE": "Captain AI",
"DESCRIPTION": "Purchase and manage credits for Captain AI features.",
"AI_CREDITS": "AI credits",
"PURCHASE_CREDITS": "Purchase credits",
"VIEW_HISTORY": "View credit history"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
@@ -403,6 +423,78 @@
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHANGE_PLAN_MODAL": {
"TITLE": "Change plan",
"DESCRIPTION": "Select a plan that best fits your needs.",
"SUBSCRIBE_TITLE": "Choose a plan",
"SUBSCRIBE_DESCRIPTION": "Select a plan to subscribe and unlock all features.",
"CURRENT_PLAN": "Current plan",
"CREDITS_MONTH": "{credits} credits/month",
"PER_USER_MONTH": "/user/month",
"AI_CREDITS_MONTH": "AI credits/month",
"CANCEL": "Cancel",
"SELECT_PLAN": "Select plan",
"CHANGE_PLAN": "Change plan",
"SUBSCRIBE": "Subscribe"
},
"CANCEL_MODAL": {
"TITLE": "Cancel subscription",
"WARNING_TITLE": "You're about to cancel your {plan} plan",
"WARNING_DESCRIPTION": "Your subscription will remain active until {date}. After that, you'll lose access to:",
"FEATURE_AI_CREDITS": "AI credits for Captain features",
"FEATURE_TEAM_COLLABORATION": "Team collaboration tools",
"FEATURE_PRIORITY_SUPPORT": "Priority customer support",
"KEEP_SUBSCRIPTION": "Keep subscription",
"CANCEL_SUBSCRIPTION": "Cancel subscription"
},
"FEEDBACK_MODAL": {
"TITLE": "Help us improve",
"DESCRIPTION": "We're sorry to see you go. Please help us improve by sharing your reason for cancelling.",
"REASON_LABEL": "Why are you cancelling?",
"REASONS": {
"TOO_EXPENSIVE": "It's too expensive",
"NOT_USING": "I'm not using it enough",
"MISSING_FEATURES": "Missing features I need",
"BETTER_ALTERNATIVE": "Found a better alternative",
"TECHNICAL_ISSUES": "Technical issues",
"OTHER": "Other reason"
},
"FEEDBACK_LABEL": "Additional feedback (optional)",
"FEEDBACK_PLACEHOLDER": "Tell us more about your experience...",
"GO_BACK": "Go back",
"SUBMIT_CANCEL": "Submit and cancel"
},
"PURCHASE_MODAL": {
"TITLE": "Purchase AI credits",
"DESCRIPTION": "Choose a credit package that suits your needs.",
"MOST_POPULAR": "Most popular",
"CREDITS": "credits",
"NOTE": "Credits will be added to your account immediately and are non-refundable. Unused credits expire after 12 months.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase"
},
"CREDIT_HISTORY": {
"TITLE": "Credit history",
"DESCRIPTION": "View your credit purchases and allocations.",
"NAME": "Name",
"CREDITS": "Credits",
"SOURCE": "Source",
"EFFECTIVE_AT": "Effective at",
"EXPIRES_AT": "Expires at",
"NO_RECORDS": "No credit history found.",
"CLOSE": "Close"
},
"HELP": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"CHAT": "Chat with us"
},
"ALERTS": {
"PLAN_CHANGED": "Your plan has been changed successfully.",
"SEATS_UPDATED": "Number of seats updated successfully.",
"SUBSCRIPTION_CANCELLED": "Your subscription has been scheduled for cancellation.",
"CREDITS_PURCHASED": "Credits purchased successfully."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
@@ -482,46 +574,6 @@
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
"TITLE": "Conversation Workflows",
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
}
},
"REQUIRED_ATTRIBUTES": {
"TITLE": "Attributes required on resolution",
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
"NO_ATTRIBUTES": "No attributes added yet",
"ADD": {
"TITLE": "Add Attributes",
"SEARCH_PLACEHOLDER": "Search attributes"
},
"SAVE": {
"SUCCESS": "Required attributes updated",
"ERROR": "Could not update required attributes, please try again"
},
"MODAL": {
"TITLE": "Resolve conversation",
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
"ACTIONS": {
"RESOLVE": "Resolve conversation",
"CANCEL": "Cancel"
},
"PLACEHOLDERS": {
"TEXT": "Write a note...",
"NUMBER": "Enter a number",
"LINK": "Add a link",
"DATE": "Pick a date",
"LIST": "Select an option"
},
"CHECKBOX": {
"YES": "Yes",
"NO": "No"
}
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
@@ -2,32 +2,20 @@
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import EditAttribute from './EditAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
import { useI18n } from 'vue-i18n';
import {
useStoreGetters,
useStore,
useMapGetter,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
const { t } = useI18n();
const getters = useStoreGetters();
const store = useStore();
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const showAddPopup = ref(false);
const selectedTabIndex = ref(0);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const openAddPopup = () => {
showAddPopup.value = true;
@@ -35,14 +23,6 @@ const openAddPopup = () => {
const hideAddPopup = () => {
showAddPopup.value = false;
};
const hideEditPopup = () => {
showEditPopup.value = false;
selectedAttribute.value = {};
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const tabs = computed(() => {
return [
@@ -69,63 +49,9 @@ const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = payload => {
selectedTabIndex.value = typeof payload === 'number' ? payload : payload?.key;
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
const handleEditAttribute = attribute => {
selectedAttribute.value = attribute;
showEditPopup.value = true;
};
const handleDeleteAttribute = attribute => {
selectedAttribute.value = attribute;
showDeletePopup.value = true;
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const hasPreChatBadge = attribute => {
return (inboxes.value || []).some(inbox => {
const fields =
inbox?.pre_chat_form_options?.pre_chat_fields ||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
[];
return fields.some(field => field.name === attribute.attribute_key);
});
};
const buildBadges = attribute => {
const badges = [];
if (hasPreChatBadge(attribute)) {
badges.push({
type: 'pre-chat',
});
}
if (
attribute.attribute_model === 'conversation_attribute' &&
requiredAttributeKeys.value.includes(attribute.attribute_key)
) {
badges.push({
type: 'resolution',
});
}
return badges;
};
const derivedAttributes = computed(() =>
attributes.value.map(attribute => ({
...attribute,
label: attribute.attribute_display_name,
type: attribute.attribute_display_type,
value: attribute.attribute_key,
badges: buildBadges(attribute),
}))
);
</script>
<template>
@@ -151,25 +77,27 @@ const derivedAttributes = computed(() =>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabs.map(tab => ({ label: tab.name, key: tab.key }))"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@change="onClickTabChange"
<template #preBody>
<woot-tabs
class="font-medium [&_ul]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
<div class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@edit="handleEditAttribute"
@delete="handleDeleteAttribute"
/>
</div>
</div>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
</template>
<AddAttribute
v-if="showAddPopup"
@@ -177,39 +105,5 @@ const derivedAttributes = computed(() =>
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: selectedAttribute.attribute_display_name,
})
"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
selectedAttribute.attribute_display_name || ''
}`"
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: selectedAttribute.attribute_display_name,
})
"
@on-confirm="
() => {
store.dispatch('attributes/delete', selectedAttribute.id);
closeDelete();
}
"
@on-close="closeDelete"
/>
</SettingsLayout>
</template>
@@ -1,124 +1,252 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAlert } from 'dashboard/composables';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
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 SubscriptionRow from './components/SubscriptionRow.vue';
import SeatStepper from './components/SeatStepper.vue';
import ChangePlanModal from './components/ChangePlanModal.vue';
import CancelSubscriptionModal from './components/CancelSubscriptionModal.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import CreditHistoryModal from './components/CreditHistoryModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
fetchLimits: fetchCaptainLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const pricingPlans = useMapGetter('accounts/getPricingPlans');
const topupOptions = useMapGetter('accounts/getTopupOptions');
const creditGrants = useMapGetter('accounts/getCreditGrants');
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
// Modal refs
const changePlanModalRef = ref(null);
const cancelModalRef = ref(null);
const purchaseCreditsModalRef = ref(null);
const creditHistoryModalRef = ref(null);
// State
const isWaitingForBilling = ref(false);
const isEditingSeats = ref(false);
const editedSeats = ref(0);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
/**
* Computed property for plan name
* @returns {string|undefined}
*/
const planName = computed(() => {
return customAttributes.value.plan_name;
});
const planName = computed(() => customAttributes.value.plan_name);
const currentPlanId = computed(
() => customAttributes.value.stripe_pricing_plan_id
);
const subscriptionId = computed(
() => customAttributes.value.stripe_subscription_id
);
const subscribedQuantity = computed(
() => customAttributes.value.subscribed_quantity
);
const subscriptionStatus = computed(
() => customAttributes.value.subscription_status
);
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
*/
const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
// Check if user has an active subscription (required for seat changes and cancellation)
const hasActiveSubscription = computed(() => !!subscriptionId.value);
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
const subscriptionEndsAt = computed(() => {
if (!customAttributes.value.subscription_ends_at) return '';
const endDate = new Date(customAttributes.value.subscription_ends_at);
return format(endDate, 'dd MMM, yyyy');
});
/**
* Computed property indicating if user has a billing plan
* @returns {boolean}
*/
const hasABillingPlan = computed(() => {
return !!planName.value;
const subscriptionCancelledAt = computed(() => {
if (!customAttributes.value.subscription_cancelled_at) return '';
const cancelledDate = new Date(customAttributes.value.subscription_cancelled_at);
return format(cancelledDate, 'dd MMM, yyyy');
});
const hasABillingPlan = computed(() => !!planName.value);
const isCancelled = computed(() => {
return subscriptionStatus.value === 'cancel_at_period_end';
});
// Captain credits
const creditsUsed = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.consumed || 0;
});
const creditsTotal = computed(() => {
if (!responseLimits.value) return 0;
return responseLimits.value.totalCount || 0;
});
// Actions
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch captain limits to show AI credits
fetchCaptainLimits();
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const onClickBillingPortal = () => {
const openBillingPortal = () => {
store.dispatch('accounts/checkout');
};
const onToggleChatWindow = () => {
const openChatWidget = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
// Modal handlers
const openChangePlanModal = async () => {
await store.dispatch('accounts/fetchPricingPlans');
changePlanModalRef.value?.open();
};
const handlePlanSelect = async plan => {
try {
// If no active subscription, use subscribe API (for Hacker plan users)
if (!hasActiveSubscription.value) {
await store.dispatch('accounts/subscribeToPlan', {
pricingPlanId: plan.id,
quantity: 1,
});
// subscribeToPlan redirects to Stripe, so no need to close modal
return;
}
// Otherwise use change plan API (for existing subscribers)
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: plan.id,
quantity: subscribedQuantity.value || 1,
});
changePlanModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.PLAN_CHANGED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Seats editing
const startEditingSeats = () => {
editedSeats.value = subscribedQuantity.value || 1;
isEditingSeats.value = true;
};
const cancelEditingSeats = () => {
isEditingSeats.value = false;
};
const saveSeats = async newSeats => {
if (newSeats === subscribedQuantity.value) {
isEditingSeats.value = false;
return;
}
try {
await store.dispatch('accounts/changePricingPlan', {
pricingPlanId: currentPlanId.value,
quantity: newSeats,
});
isEditingSeats.value = false;
useAlert(t('BILLING_SETTINGS.ALERTS.SEATS_UPDATED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Cancel subscription
const openCancelModal = () => {
cancelModalRef.value?.open();
};
const handleCancelConfirm = async ({ reason, feedback }) => {
try {
await store.dispatch('accounts/cancelAccountSubscription', {
reason,
feedback,
});
cancelModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.SUBSCRIPTION_CANCELLED'));
await store.dispatch('accounts/subscription');
} catch {
// Error already handled
}
};
// Purchase credits
const openPurchaseCreditsModal = async () => {
await store.dispatch('accounts/fetchTopupOptions');
purchaseCreditsModalRef.value?.open();
};
const handlePurchaseCredits = async option => {
try {
await store.dispatch('accounts/purchaseCredits', {
credits: option.credits,
});
purchaseCreditsModalRef.value?.close();
useAlert(t('BILLING_SETTINGS.ALERTS.CREDITS_PURCHASED'));
fetchCaptainLimits();
} catch {
// Error already handled
}
};
// Credit history
const openCreditHistoryModal = async () => {
await store.dispatch('accounts/fetchCreditGrants');
creditHistoryModalRef.value?.open();
};
onMounted(handleBillingPageLogic);
</script>
@@ -137,92 +265,187 @@ onMounted(handleBillingPageLogic);
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS.TITLE')"
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
feature-name="billing"
/>
>
<template #actions>
<Button
solid
blue
sm
:label="$t('BILLING_SETTINGS.BILLING_PORTAL')"
:is-loading="uiFlags.isCheckoutInProcess"
@click="openBillingPortal"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
<section class="flex flex-col gap-6">
<a
href="https://www.chatwoot.com/pricing"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-n-blue-text hover:underline"
>
<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')"
{{ $t('BILLING_SETTINGS.VIEW_ALL_PLANS') }}
<span class="i-lucide-chevron-right size-4" />
</a>
<!-- Subscription Card -->
<BillingCard
:title="$t('BILLING_SETTINGS.SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.SUBSCRIPTION.DESCRIPTION')"
>
<div class="px-5">
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CURRENT_PLAN')"
:value="planName"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_PLAN')"
:action-disabled="isCancelled"
@action="openChangePlanModal"
/>
<DetailItem
v-if="subscribedQuantity"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
<SubscriptionRow
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.NUMBER_OF_SEATS')"
:value="subscribedQuantity"
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_SEATS')"
:show-action="
!isEditingSeats && !isCancelled && hasActiveSubscription
"
@action="startEditingSeats"
>
<template v-if="isEditingSeats" #value>
<SeatStepper
v-model="editedSeats"
:min="1"
:is-loading="uiFlags.isChangingPlan"
@save="saveSeats"
@cancel="cancelEditingSeats"
/>
</template>
</SubscriptionRow>
<SubscriptionRow
v-if="isCancelled"
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CANCELLED_ON')"
:value="subscriptionCancelledAt"
/>
<DetailItem
v-if="subscriptionRenewsOn"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
<SubscriptionRow
:label="
isCancelled
? $t('BILLING_SETTINGS.SUBSCRIPTION.ENDS_ON')
: $t('BILLING_SETTINGS.SUBSCRIPTION.RENEWS_ON')
"
:value="subscriptionEndsAt"
:action-text="
isCancelled || !hasActiveSubscription
? ''
: $t('BILLING_SETTINGS.SUBSCRIPTION.CANCEL_SUBSCRIPTION')
"
@action="openCancelModal"
/>
</div>
</BillingCard>
<!-- Captain AI Card -->
<BillingCard
v-if="captainEnabled"
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN_AI.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
<Button
solid
slate
sm
:label="$t('BILLING_SETTINGS.CAPTAIN_AI.PURCHASE_CREDITS')"
@click="openPurchaseCreditsModal"
/>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<div class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
v-bind="responseLimits"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.AI_CREDITS')"
:consumed="creditsUsed"
:total-count="creditsTotal"
/>
</div>
<div v-if="captainLimits && documentLimits" class="px-5">
<BillingMeter
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
v-bind="documentLimits"
/>
<div class="px-5 pt-2">
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click="openCreditHistoryModal"
>
{{ $t('BILLING_SETTINGS.CAPTAIN_AI.VIEW_HISTORY') }}
</button>
</div>
</BillingCard>
<!-- Captain AI Upgrade Card (when not enabled) -->
<BillingCard
v-else
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
>
<template #action>
<ButtonV4 sm solid slate @click="onClickBillingPortal">
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
</ButtonV4>
<Button
solid
slate
sm
:label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@click="openBillingPortal"
/>
</template>
</BillingCard>
<!-- Help Section -->
<BillingHeader
class="px-1 mt-5"
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
class="px-1 mt-2"
:title="$t('BILLING_SETTINGS.HELP.TITLE')"
:description="$t('BILLING_SETTINGS.HELP.DESCRIPTION')"
>
<ButtonV4
sm
<Button
solid
slate
icon="i-lucide-life-buoy"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
sm
icon="i-lucide-message-circle"
:label="$t('BILLING_SETTINGS.HELP.CHAT')"
@click="openChatWidget"
/>
</BillingHeader>
</section>
<!-- Modals -->
<ChangePlanModal
ref="changePlanModalRef"
:plans="pricingPlans"
:current-plan-id="currentPlanId"
:is-loading="uiFlags.isChangingPlan || uiFlags.isSubscribing"
:is-subscribe-mode="!hasActiveSubscription"
@select="handlePlanSelect"
/>
<CancelSubscriptionModal
ref="cancelModalRef"
:plan-name="planName"
:renews-on="subscriptionEndsAt"
:is-loading="uiFlags.isCancellingSubscription"
@confirm="handleCancelConfirm"
/>
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
:options="topupOptions"
:is-loading="uiFlags.isPurchasingCredits"
@purchase="handlePurchaseCredits"
/>
<CreditHistoryModal
ref="creditHistoryModalRef"
:credit-grants="creditGrants"
:is-loading="uiFlags.isFetchingCreditGrants"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CancellationFeedbackForm from './CancellationFeedbackForm.vue';
defineProps({
planName: {
type: String,
default: '',
},
renewsOn: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const step = ref('warning'); // 'warning' | 'feedback'
const features = [
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_AI_CREDITS',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_TEAM_COLLABORATION',
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_PRIORITY_SUPPORT',
];
const handleProceedToFeedback = () => {
step.value = 'feedback';
};
const handleBack = () => {
step.value = 'warning';
};
const handleFeedbackSubmit = ({ reason, feedback }) => {
emit('confirm', { reason, feedback });
};
const handleClose = () => {
step.value = 'warning';
emit('close');
};
const open = () => {
step.value = 'warning';
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
step === 'warning'
? t('BILLING_SETTINGS.CANCEL_MODAL.TITLE')
: t('BILLING_SETTINGS.FEEDBACK_MODAL.TITLE')
"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Warning Step -->
<template v-if="step === 'warning'">
<div class="p-4 border rounded-lg bg-n-ruby-9/5 border-n-ruby-9/20">
<div class="flex gap-3">
<span
class="flex-shrink-0 i-lucide-alert-triangle size-5 text-n-ruby-9"
/>
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-n-ruby-11">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_TITLE', {
plan: planName,
})
}}
</p>
<p class="text-sm text-n-ruby-11/80">
{{
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_DESCRIPTION', {
date: renewsOn,
})
}}
</p>
<ul class="mt-2 space-y-1">
<li
v-for="feature in features"
:key="feature"
class="flex items-center gap-2 text-sm text-n-ruby-11"
>
<span class="size-1.5 rounded-full bg-n-ruby-9" />
{{ t(feature) }}
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center gap-3 mt-6">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CANCEL_MODAL.KEEP_SUBSCRIPTION')"
class="flex-1"
@click="close"
/>
<Button
color="ruby"
solid
:label="t('BILLING_SETTINGS.CANCEL_MODAL.CANCEL_SUBSCRIPTION')"
class="flex-1"
@click="handleProceedToFeedback"
/>
</div>
</template>
<!-- Feedback Step -->
<template v-else>
<CancellationFeedbackForm
:is-loading="isLoading"
@submit="handleFeedbackSubmit"
@back="handleBack"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'back']);
const { t } = useI18n();
const selectedReason = ref('');
const additionalFeedback = ref('');
const cancellationReasons = [
{
key: 'too_expensive',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TOO_EXPENSIVE',
},
{
key: 'not_using',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.NOT_USING',
},
{
key: 'missing_features',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.MISSING_FEATURES',
},
{
key: 'better_alternative',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.BETTER_ALTERNATIVE',
},
{
key: 'technical_issues',
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TECHNICAL_ISSUES',
},
{ key: 'other', label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.OTHER' },
];
const handleSubmit = () => {
emit('submit', {
reason: selectedReason.value,
feedback: additionalFeedback.value,
});
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.DESCRIPTION') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.REASON_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<label
v-for="reason in cancellationReasons"
:key="reason.key"
class="flex items-center gap-3 p-3 transition-colors border rounded-lg cursor-pointer border-n-weak hover:bg-n-alpha-1"
:class="{
'border-n-teal-9 bg-n-teal-9/5': selectedReason === reason.key,
}"
>
<input
v-model="selectedReason"
type="radio"
name="cancellation-reason"
:value="reason.key"
class="w-4 h-4 accent-n-teal-9"
/>
<span class="text-sm text-n-slate-12">{{ t(reason.label) }}</span>
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-11">
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_LABEL') }}
</label>
<textarea
v-model="additionalFeedback"
rows="3"
class="w-full p-3 text-sm transition-colors border rounded-lg resize-none bg-n-alpha-1 border-n-weak text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none focus:border-n-strong"
:placeholder="t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_PLACEHOLDER')"
/>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border rounded-lg border-n-weak text-n-slate-12 hover:bg-n-alpha-1"
:disabled="isLoading"
@click="handleBack"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.GO_BACK') }}
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-medium text-white transition-colors rounded-lg bg-n-slate-9 hover:bg-n-slate-10 disabled:opacity-50"
:disabled="!selectedReason || isLoading"
@click="handleSubmit"
>
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.SUBMIT_CANCEL') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PlanCard from './PlanCard.vue';
const props = defineProps({
plans: {
type: Array,
default: () => [],
},
currentPlanId: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
isSubscribeMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const selectedPlanId = ref(props.currentPlanId);
const selectedPlan = computed(() => {
return filteredPlans.value.find(p => p.id === selectedPlanId.value);
});
// Filter out Hacker plan when user already has a subscription (change plan mode)
const filteredPlans = computed(() => {
if (props.isSubscribeMode) {
// In subscribe mode (no subscription), show all plans including Hacker
return props.plans;
}
// In change plan mode (has subscription), hide Hacker plan
return props.plans.filter(
plan => plan.display_name?.toLowerCase() !== 'hacker'
);
});
const isCurrentPlanSelected = computed(() => {
return selectedPlanId.value === props.currentPlanId;
});
const modalTitle = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_TITLE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.TITLE');
});
const modalDescription = computed(() => {
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_DESCRIPTION');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.DESCRIPTION');
});
const confirmButtonLabel = computed(() => {
if (isCurrentPlanSelected.value) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN');
}
if (props.isSubscribeMode) {
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE');
}
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CHANGE_PLAN');
});
const handlePlanSelect = plan => {
selectedPlanId.value = plan.id;
};
const handleConfirm = () => {
if (!isCurrentPlanSelected.value && selectedPlan.value) {
emit('select', selectedPlan.value);
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
selectedPlanId.value = props.currentPlanId;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="modalTitle"
:description="modalDescription"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-3 gap-4">
<PlanCard
v-for="plan in filteredPlans"
:key="plan.id"
:plan="plan"
:is-selected="selectedPlanId === plan.id"
:is-current="plan.id === currentPlanId"
:is-disabled="isLoading || plan.id === currentPlanId"
@select="handlePlanSelect"
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CANCEL')"
class="w-full"
:disabled="isLoading"
@click="close"
/>
<Button
color="slate"
solid
:label="confirmButtonLabel"
class="w-full"
:disabled="isCurrentPlanSelected || isLoading"
:is-loading="isLoading"
@click="handleConfirm"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,146 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
creditGrants: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const dialogRef = ref(null);
const formattedGrants = computed(() => {
return props.creditGrants.map(grant => ({
...grant,
formattedEffectiveAt: grant.effective_at
? format(new Date(grant.effective_at), 'dd MMM, yyyy')
: '-',
formattedExpiresAt: grant.expires_at
? format(new Date(grant.expires_at), 'dd MMM, yyyy')
: '-',
}));
});
const handleClose = () => {
emit('close');
};
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.CREDIT_HISTORY.TITLE')"
:description="t('BILLING_SETTINGS.CREDIT_HISTORY.DESCRIPTION')"
width="2xl"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
@close="handleClose"
>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<span class="i-lucide-loader-2 size-6 animate-spin text-n-slate-11" />
</div>
<div v-else-if="formattedGrants.length === 0" class="py-8 text-center">
<span
class="i-lucide-credit-card size-12 text-n-slate-9 mx-auto block mb-3"
/>
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NO_RECORDS') }}
</p>
</div>
<div v-else class="overflow-hidden border rounded-lg border-n-weak">
<table class="w-full">
<thead class="bg-n-alpha-1">
<tr>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NAME') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.CREDITS') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.SOURCE') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EFFECTIVE_AT') }}
</th>
<th
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
>
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EXPIRES_AT') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr v-for="grant in formattedGrants" :key="grant.id">
<td class="px-4 py-3 text-sm text-n-slate-12">
{{ grant.name || '-' }}
</td>
<td
class="px-4 py-3 text-sm font-medium tabular-nums text-n-slate-12"
>
{{ grant.credits?.toLocaleString() || 0 }}
</td>
<td class="px-4 py-3">
<span
class="px-2 py-1 text-xs font-medium rounded bg-n-alpha-2 text-n-slate-11"
>
{{ grant.source || grant.category || '-' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedEffectiveAt }}
</td>
<td class="px-4 py-3 text-sm text-n-slate-11">
{{ grant.formattedExpiresAt }}
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="flex justify-end w-full">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.CREDIT_HISTORY.CLOSE')"
@click="close"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
isPopular: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
const { t } = useI18n();
const formattedCredits = computed(() => {
return props.credits.toLocaleString();
});
const formattedAmount = computed(() => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(props.amount);
});
const cardClasses = computed(() => {
const baseClasses =
'relative flex flex-col gap-2 p-4 transition-all border rounded-xl cursor-pointer';
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5`;
}
return `${baseClasses} border-n-weak hover:border-n-strong`;
});
</script>
<template>
<div :class="cardClasses" @click="$emit('select')">
<div
v-if="isPopular"
class="absolute px-2 py-1 text-xs font-medium text-white rounded -top-3 left-3 bg-n-teal-9"
>
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.MOST_POPULAR') }}
</div>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
>
<span class="text-white i-lucide-check size-4" />
</div>
<div class="text-2xl font-semibold text-n-slate-12">
{{ formattedCredits }}
</div>
<div class="text-xs font-medium tracking-wider uppercase text-n-slate-10">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.CREDITS') }}
</div>
<div class="mt-2">
<span class="text-lg font-semibold text-n-slate-12">{{
formattedAmount
}}</span>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
plan: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isCurrent: {
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
const { t } = useI18n();
const displayPrice = computed(() => {
const licenseFee = props.plan.components?.find(c => c.type === 'license_fee');
const amount = licenseFee?.unit_amount || 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
});
const monthlyCredits = computed(() => {
const serviceAction = props.plan.components?.find(
c => c.type === 'service_action'
);
return serviceAction?.credit_amount || 0;
});
const cardClasses = computed(() => {
const baseClasses = 'relative flex flex-col gap-2 p-4 transition-all border rounded-xl';
if (props.isDisabled) {
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 opacity-70 cursor-not-allowed`;
}
return `${baseClasses} border-n-weak opacity-50 cursor-not-allowed`;
}
if (props.isSelected) {
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 cursor-pointer`;
}
return `${baseClasses} border-n-weak hover:border-n-strong cursor-pointer`;
});
const handleClick = () => {
if (!props.isDisabled) {
// Emit is handled in template
}
};
</script>
<template>
<div :class="cardClasses" @click="!isDisabled && $emit('select', plan)">
<div
v-if="isCurrent"
class="absolute px-2 py-1 text-xs font-medium rounded -top-3 left-3 bg-n-solid-3 text-n-slate-11"
>
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN') }}
</div>
<div
v-if="isSelected"
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
>
<span class="text-white i-lucide-check size-4" />
</div>
<h3 class="text-lg font-medium text-n-slate-12">
{{ plan.display_name }}
</h3>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-semibold text-n-slate-12">
{{ displayPrice }}
</span>
<span class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.PER_USER_MONTH') }}
</span>
</div>
<div class="text-sm text-n-slate-11">
{{ monthlyCredits }}
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.AI_CREDITS_MONTH') }}
</div>
</div>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
const props = defineProps({
options: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['purchase', 'close']);
const { t } = useI18n();
const dialogRef = ref(null);
const selectedCredits = ref(null);
// The 3rd option (5000 credits) is marked as most popular based on Figma
const popularCreditsAmount = 5000;
const selectedOption = computed(() => {
return props.options.find(o => o.credits === selectedCredits.value);
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const handlePurchase = () => {
if (selectedOption.value) {
emit('purchase', selectedOption.value);
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = props.options.find(
o => o.credits === popularCreditsAmount
);
selectedCredits.value = popularOption?.credits || props.options[0]?.credits;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('BILLING_SETTINGS.PURCHASE_MODAL.TITLE')"
:description="t('BILLING_SETTINGS.PURCHASE_MODAL.DESCRIPTION')"
width="xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in options"
:key="option.credits"
:credits="option.credits"
:amount="option.amount"
:is-popular="option.credits === popularCreditsAmount"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.NOTE') }}
</p>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="teal"
solid
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,104 @@
<script setup>
import { ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: {
type: Number,
required: true,
},
min: {
type: Number,
default: 1,
},
max: {
type: Number,
default: 999,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
const localValue = ref(props.modelValue);
watch(
() => props.modelValue,
newVal => {
localValue.value = newVal;
}
);
const decrement = () => {
if (localValue.value > props.min) {
localValue.value -= 1;
emit('update:modelValue', localValue.value);
}
};
const increment = () => {
if (localValue.value < props.max) {
localValue.value += 1;
emit('update:modelValue', localValue.value);
}
};
const handleSave = () => {
emit('save', localValue.value);
};
const handleCancel = () => {
localValue.value = props.modelValue;
emit('cancel');
};
</script>
<template>
<div class="flex items-center gap-4">
<div
class="flex items-center overflow-hidden border rounded-lg border-n-weak"
>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue <= min || isLoading"
@click="decrement"
>
<span class="i-lucide-minus size-4" />
</button>
<span
class="flex items-center justify-center w-12 h-10 text-base font-medium tabular-nums text-n-slate-12"
>
{{ localValue }}
</span>
<button
type="button"
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
:disabled="localValue >= max || isLoading"
@click="increment"
>
<span class="i-lucide-plus size-4" />
</button>
</div>
<div class="flex items-center gap-2">
<Button
variant="link"
color="slate"
label="Cancel"
:disabled="isLoading"
@click="handleCancel"
/>
<Button
solid
slate
sm
label="Save"
:is-loading="isLoading"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
default: '',
},
actionText: {
type: String,
default: '',
},
actionDisabled: {
type: Boolean,
default: false,
},
showAction: {
type: Boolean,
default: true,
},
});
defineEmits(['action']);
</script>
<template>
<div
class="flex items-center justify-between py-4 border-b border-n-weak last:border-b-0"
>
<div class="flex flex-col gap-1">
<span
class="text-xs font-medium tracking-wider uppercase text-n-slate-10"
>
{{ label }}
</span>
<slot name="value">
<span class="text-base font-medium text-n-slate-12">
{{ value }}
</span>
</slot>
</div>
<slot name="action">
<button
v-if="actionText && showAction"
type="button"
class="text-sm font-medium transition-colors text-n-slate-11 hover:text-n-slate-12 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="actionDisabled"
@click="$emit('action')"
>
{{ actionText }}
</button>
</slot>
</div>
</template>
@@ -1,28 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import ConversationWorkflowIndex from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
component: SettingsWrapper,
children: [
{
path: '',
redirect: to => {
return { name: 'conversation_workflow_index', params: to.params };
},
},
{
path: 'index',
name: 'conversation_workflow_index',
component: ConversationWorkflowIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};
@@ -1,28 +0,0 @@
<script setup>
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
</script>
<template>
<SettingsLayout :no-records-found="false" class="gap-10">
<template #header>
<BaseSettingsHeader
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
feature-name="assignment-policy"
/>
</template>
<template #body>
<div class="flex">
<ConversationRequiredAttributes
:title="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE')"
:description="
$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION')
"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -24,7 +24,6 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
export default {
routes: [
@@ -64,6 +63,5 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...conversationWorkflow.routes,
],
};
@@ -18,7 +18,17 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingPricingPlans: false,
isFetchingTopupOptions: false,
isFetchingCreditGrants: false,
isChangingPlan: false,
isCancellingSubscription: false,
isPurchasingCredits: false,
isSubscribing: false,
},
pricingPlans: [],
topupOptions: [],
creditGrants: [],
};
export const getters = {
@@ -28,6 +38,15 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getPricingPlans($state) {
return $state.pricingPlans;
},
getTopupOptions($state) {
return $state.topupOptions;
},
getCreditGrants($state) {
return $state.creditGrants;
},
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
@@ -152,6 +171,120 @@ export const actions = {
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
// V2 Billing Actions
fetchPricingPlans: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingPricingPlans: true });
try {
const response = await EnterpriseAccountAPI.getPricingPlans();
commit(types.default.SET_PRICING_PLANS, response.data.pricing_plans);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingPricingPlans: false,
});
}
},
fetchTopupOptions: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingTopupOptions: true });
try {
const response = await EnterpriseAccountAPI.getTopupOptions();
commit(types.default.SET_TOPUP_OPTIONS, response.data.topup_options);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingTopupOptions: false,
});
}
},
fetchCreditGrants: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingCreditGrants: true });
try {
const response = await EnterpriseAccountAPI.getCreditGrants();
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingCreditGrants: false,
});
}
},
changePricingPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: true });
try {
const response = await EnterpriseAccountAPI.changePricingPlan(
pricingPlanId,
quantity
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: false });
}
},
cancelAccountSubscription: async ({ commit }, { reason, feedback }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: true,
});
try {
const response = await EnterpriseAccountAPI.cancelSubscription(
reason,
feedback
);
commit(types.default.EDIT_ACCOUNT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isCancellingSubscription: false,
});
}
},
purchaseCredits: async ({ commit }, { credits }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: true });
try {
const response = await EnterpriseAccountAPI.topupCredits(credits);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: false });
}
},
subscribeToPlan: async ({ commit }, { pricingPlanId, quantity }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: true });
try {
const response = await EnterpriseAccountAPI.subscribeToPlan(
pricingPlanId,
quantity
);
// Redirect to Stripe checkout
if (response.data.redirect_url) {
window.location = response.data.redirect_url;
}
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: false });
}
},
};
export const mutations = {
@@ -164,6 +297,15 @@ export const mutations = {
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
[types.default.SET_PRICING_PLANS]($state, data) {
$state.pricingPlans = data;
},
[types.default.SET_TOPUP_OPTIONS]($state, data) {
$state.topupOptions = data;
},
[types.default.SET_CREDIT_GRANTS]($state, data) {
$state.creditGrants = data;
},
};
export default {
@@ -76,13 +76,16 @@ export default {
EDIT_INBOXES: 'EDIT_INBOXES',
DELETE_INBOXES: 'DELETE_INBOXES',
// Agent
// Account
SET_ACCOUNT_UI_FLAG: 'SET_ACCOUNT_UI_FLAG',
SET_ACCOUNT_LIMITS: 'SET_ACCOUNT_LIMITS',
SET_ACCOUNTS: 'SET_ACCOUNTS',
ADD_ACCOUNT: 'ADD_ACCOUNT',
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
DELETE_ACCOUNT: 'DELETE_AGENT',
SET_PRICING_PLANS: 'SET_PRICING_PLANS',
SET_TOPUP_OPTIONS: 'SET_TOPUP_OPTIONS',
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
// Agent
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',
+2 -6
View File
@@ -37,11 +37,7 @@ class Account < ApplicationRecord
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
}
'auto_resolve_label': { 'type': %w[string null] }
},
'required': [],
'additionalProperties': true
@@ -59,7 +55,7 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async