Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Shivam Mishra
2025-10-13 12:30:50 +05:30
committed by GitHub
905 changed files with 30845 additions and 1827 deletions
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
{
@@ -124,4 +125,17 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];
@@ -0,0 +1,138 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
const customTools = useMapGetter('captainCustomTools/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
const createDialogRef = ref(null);
const deleteDialogRef = ref(null);
const selectedTool = ref(null);
const dialogType = ref('');
const fetchCustomTools = (page = 1) => {
store.dispatch('captainCustomTools/get', { page });
};
const onPageChange = page => fetchCustomTools(page);
const openCreateDialog = () => {
dialogType.value = 'create';
selectedTool.value = null;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleEdit = tool => {
dialogType.value = 'edit';
selectedTool.value = tool;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleDelete = tool => {
selectedTool.value = tool;
nextTick(() => deleteDialogRef.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
const tool = customTools.value.find(t => t.id === id);
if (action === 'edit') {
handleEdit(tool);
} else if (action === 'delete') {
handleDelete(tool);
}
};
const handleDialogClose = () => {
dialogType.value = '';
selectedTool.value = null;
};
const onDeleteSuccess = () => {
selectedTool.value = null;
// Check if page will be empty after deletion
if (customTools.value.length === 1 && customToolsMeta.value.page > 1) {
// Go to previous page if current page will be empty
onPageChange(customToolsMeta.value.page - 1);
} else {
// Refresh current page
fetchCustomTools(customToolsMeta.value.page);
}
};
onMounted(() => {
fetchCustomTools();
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="customToolsMeta.totalCount"
:current-page="customToolsMeta.page"
:show-pagination-footer="!isFetching && !!customTools.length"
:is-fetching="isFetching"
:is-empty="!customTools.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
@update:current-page="onPageChange"
@click="openCreateDialog"
>
<template #paywall>
<CaptainPaywall />
</template>
<template #emptyState>
<CustomToolsPageEmptyState @click="openCreateDialog" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<CustomToolCard
v-for="tool in customTools"
:id="tool.id"
:key="tool.id"
:title="tool.title"
:description="tool.description"
:endpoint-url="tool.endpoint_url"
:http-method="tool.http_method"
:auth-type="tool.auth_type"
:param-schema="tool.param_schema"
:enabled="tool.enabled"
:created-at="tool.created_at"
:updated-at="tool.updated_at"
@action="handleAction"
/>
</div>
</template>
</PageLayout>
<CreateCustomToolDialog
v-if="dialogType"
ref="createDialogRef"
:type="dialogType"
:selected-tool="selectedTool"
@close="handleDialogClose"
/>
<DeleteDialog
v-if="selectedTool"
ref="deleteDialogRef"
:entity="selectedTool"
type="CustomTools"
translation-key="CUSTOM_TOOLS"
@delete-success="onDeleteSuccess"
/>
</template>
@@ -1,21 +1,34 @@
<script setup>
import { watch, computed } from 'vue';
import { watch, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'next/spinner/Spinner.vue';
const { contactId } = defineProps({
contactId: { type: String, required: true },
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
const notes = computed(() => notGetterFn.value(contactId));
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const contactId = computed(() => props.contactId);
const noteContent = ref('');
const shouldShowCreateModal = ref(false);
const notes = computed(() => {
if (!contactId.value) {
return [];
}
return notesByContact.value(contactId.value) || [];
});
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
@@ -24,28 +37,130 @@ const getWrittenBy = ({ user } = {}) => {
: user?.name || t('CONVERSATION.BOT');
};
const openCreateModal = () => {
if (!contactId.value) {
return;
}
noteContent.value = '';
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
noteContent.value = '';
};
const onAdd = async () => {
if (!contactId.value || !noteContent.value || isCreatingNote.value) {
return;
}
await store.dispatch('contactNotes/create', {
content: noteContent.value,
contactId: contactId.value,
});
noteContent.value = '';
closeCreateModal();
};
const onDelete = noteId => {
if (!contactId.value || !noteId) {
return;
}
store.dispatch('contactNotes/delete', {
noteId,
contactId: contactId.value,
});
};
const keyboardEvents = {
'$mod+Enter': {
action: onAdd,
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
watch(
() => contactId,
() => store.dispatch('contactNotes/get', { contactId }),
contactId,
newContactId => {
closeCreateModal();
if (newContactId) {
store.dispatch('contactNotes/get', { contactId: newContactId });
}
},
{ immediate: true }
);
</script>
<template>
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
<Spinner />
</div>
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
</div>
<div v-else class="max-h-[300px] overflow-scroll">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="p-4 last-of-type:border-b-0"
:note="note"
collapsible
:written-by="getWrittenBy(note)"
/>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@click="openCreateModal"
/>
</div>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="notes.length"
class="flex flex-col max-h-[300px] overflow-y-auto"
>
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="py-4 last-of-type:border-b-0 px-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
collapsible
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
</p>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<div class="flex w-full flex-col gap-6 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE') }}
</h3>
<Editor
v-model="noteContent"
focus-on-mount
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
/>
<div class="flex items-center justify-end gap-3">
<NextButton
solid
blue
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
:is-loading="isCreatingNote"
:disabled="!noteContent || isCreatingNote"
@click="onAdd"
/>
</div>
</div>
</woot-modal>
</div>
</template>
@@ -1,10 +1,14 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
beta: { type: Boolean, default: false },
});
const { t } = useI18n();
</script>
<template>
@@ -17,8 +21,15 @@ defineProps({
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<slot name="description">{{ description }}</slot>
@@ -30,6 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
provider: {
type: String,
default: '',
},
customRoleId: {
type: Number,
default: null,
@@ -203,6 +207,7 @@ const resetPassword = async () => {
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right">
<Button
v-if="provider !== 'saml'"
ghost
type="button"
icon="i-lucide-lock-keyhole"
@@ -261,6 +261,7 @@ const confirmDeletion = () => {
v-if="showEditPopup"
:id="currentAgent.id"
:name="currentAgent.name"
:provider="currentAgent.provider"
:type="currentAgent.role"
:email="currentAgent.email"
:availability="currentAgent.availability_status"
@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
const { currentAccount } = useAccount();
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
@@ -24,6 +27,12 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
store.dispatch('accounts/subscription');
await store.dispatch('accounts/subscription');
fetchLimits();
}
};
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 = () => {
store.dispatch('accounts/checkout');
};
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
}
};
onMounted(fetchAccountDetails);
onMounted(handleBillingPageLogic);
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!hasABillingPlan"
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
:loading-message="
isWaitingForBilling
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -13,6 +13,7 @@ import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue'
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import InboxHealthAPI from 'dashboard/api/inboxHealth';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -21,6 +22,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -51,6 +53,7 @@ export default {
DuplicateInboxBanner,
Editor,
Avatar,
AccountHealth,
},
mixins: [inboxMixin],
setup() {
@@ -79,6 +82,9 @@ export default {
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
};
},
computed: {
@@ -175,6 +181,16 @@ export default {
},
];
}
if (this.shouldShowWhatsAppConfiguration) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'whatsappHealth',
name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -206,7 +222,8 @@ export default {
this.isASmsInbox ||
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAPIInbox
this.isAPIInbox ||
this.isATelegramChannel
);
},
inboxNameLabel() {
@@ -259,14 +276,30 @@ export default {
this.inbox.reauthorization_required
);
},
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappUnauthorized() {
return (
this.isAWhatsAppChannel &&
this.inbox.provider === 'whatsapp_cloud' &&
this.inbox.provider_config?.source === 'embedded_signup' &&
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.inbox.reauthorization_required
);
},
whatsappRegistrationIncomplete() {
if (
!this.healthData ||
!this.isAWhatsAppCloudChannel ||
!this.isEmbeddedSignupWhatsApp
) {
return false;
}
return (
this.healthData.platform_type === 'NOT_APPLICABLE' ||
this.healthData.throughput?.level === 'NOT_APPLICABLE'
);
},
},
watch: {
$route(to) {
@@ -274,15 +307,40 @@ export default {
this.fetchInboxSettings();
}
},
inbox: {
handler() {
this.fetchHealthData();
},
immediate: false,
},
},
mounted() {
this.fetchInboxSettings();
this.fetchPortals();
this.fetchHealthData();
},
methods: {
fetchPortals() {
this.$store.dispatch('portals/index');
},
async fetchHealthData() {
if (!this.inbox) return;
if (!this.isAWhatsAppCloudChannel) {
return;
}
try {
this.isLoadingHealth = true;
this.healthError = null;
const response = await InboxHealthAPI.getHealthStatus(this.inbox.id);
this.healthData = response.data;
} catch (error) {
this.healthError = error.message || 'Failed to fetch health data';
} finally {
this.isLoadingHealth = false;
}
},
handleFeatureFlag(e) {
this.selectedFeatureFlags = this.toggleInput(
this.selectedFeatureFlags,
@@ -341,7 +399,7 @@ export default {
try {
const payload = {
id: this.currentInboxId,
name: this.selectedInboxName,
name: this.selectedInboxName?.trim(),
enable_email_collect: this.emailCollectEnabled,
allow_messages_after_resolved: this.allowMessagesAfterResolved,
greeting_enabled: this.greetingEnabled,
@@ -445,7 +503,11 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<WhatsappReauthorize
v-if="whatsappUnauthorized"
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
:inbox="inbox"
/>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -457,7 +519,7 @@ export default {
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false"
>
<div class="flex flex-col mb-4 items-start gap-1">
<div class="flex flex-col gap-1 items-start mb-4">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
</label>
@@ -855,6 +917,9 @@ export default {
<div v-if="selectedTabKey === 'botConfiguration'">
<BotConfiguration :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'whatsappHealth'">
<AccountHealth :health-data="healthData" />
</div>
</section>
</div>
</template>
@@ -41,7 +41,7 @@ export default {
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.inboxName,
name: this.inboxName?.trim(),
channel: {
type: 'whatsapp',
phone_number: this.phoneNumber,
@@ -42,7 +42,7 @@ export default {
try {
const apiChannel = await this.$store.dispatch('inboxes/createChannel', {
name: this.channelName,
name: this.channelName?.trim(),
channel: {
type: 'api',
webhook_url: this.webhookUrl,
@@ -48,7 +48,7 @@ export default {
try {
const smsChannel = await this.$store.dispatch('inboxes/createChannel', {
name: this.inboxName,
name: this.inboxName?.trim(),
channel: {
type: 'sms',
phone_number: this.phoneNumber,
@@ -45,7 +45,7 @@ export default {
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.inboxName,
name: this.inboxName?.trim(),
channel: {
type: 'whatsapp',
phone_number: this.phoneNumber,
@@ -179,7 +179,7 @@ export default {
user_access_token: this.user_access_token,
page_access_token: this.selectedPage.access_token,
page_id: this.selectedPage.id,
inbox_name: this.selectedPage.name,
inbox_name: this.selectedPage.name?.trim(),
};
},
@@ -45,7 +45,7 @@ export default {
const lineChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName,
name: this.channelName?.trim(),
channel: {
type: 'line',
line_channel_id: this.lineChannelId,
@@ -85,7 +85,7 @@ export default {
'inboxes/createTwilioChannel',
{
twilio_channel: {
name: this.channelName,
name: this.channelName?.trim(),
medium: this.medium,
account_sid: this.accountSID,
api_key_sid: this.apiKeySID,
@@ -47,7 +47,7 @@ export default {
const website = await this.$store.dispatch(
'inboxes/createWebsiteChannel',
{
name: this.inboxName,
name: this.inboxName?.trim(),
greeting_enabled: this.greetingEnabled,
greeting_message: this.greetingMessage,
channel: {
@@ -42,7 +42,7 @@ export default {
const emailChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName,
name: this.channelName?.trim(),
channel: {
type: 'email',
email: this.email,
@@ -16,6 +16,10 @@ const props = defineProps({
type: Object,
required: true,
},
whatsappRegistrationIncomplete: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -28,6 +32,20 @@ const whatsappConfigurationId = computed(
() => window.chatwootConfig.whatsappConfigurationId
);
const actionLabel = computed(() => {
if (props.whatsappRegistrationIncomplete) {
return t('INBOX_MGMT.COMPLETE_REGISTRATION');
}
return '';
});
const description = computed(() => {
if (props.whatsappRegistrationIncomplete) {
return t('INBOX_MGMT.WHATSAPP_REGISTRATION_INCOMPLETE');
}
return '';
});
const reauthorizeWhatsApp = async params => {
isRequestingAuthorization.value = true;
@@ -185,6 +203,8 @@ defineExpose({
<InboxReconnectionRequired
class="mx-8 mt-5"
:is-loading="isRequestingAuthorization"
:action-label="actionLabel"
:description="description"
@reauthorize="requestAuthorization"
/>
</template>
@@ -0,0 +1,228 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ButtonV4 from 'next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
healthData: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const QUALITY_COLORS = {
GREEN: 'text-n-teal-11',
YELLOW: 'text-n-amber-11',
RED: 'text-n-ruby-11',
UNKNOWN: 'text-n-slate-12',
};
const STATUS_COLORS = {
APPROVED: 'text-n-teal-11',
PENDING_REVIEW: 'text-n-amber-11',
AVAILABLE_WITHOUT_REVIEW: 'text-n-teal-11',
REJECTED: 'text-n-ruby-9',
DECLINED: 'text-n-ruby-9',
};
const MODE_COLORS = {
LIVE: 'text-n-teal-11',
SANDBOX: 'text-n-slate-11',
};
const healthItems = computed(() => {
if (!props.healthData) {
return [];
}
const {
display_phone_number: displayPhoneNumber,
verified_name: verifiedName,
name_status: nameStatus,
quality_rating: qualityRating,
messaging_limit_tier: messagingLimitTier,
account_mode: accountMode,
} = props.healthData;
return [
{
key: 'displayPhoneNumber',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.LABEL'),
value: displayPhoneNumber || 'N/A',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_PHONE_NUMBER.TOOLTIP'
),
show: true,
},
{
key: 'verifiedName',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.LABEL'),
value: verifiedName || 'N/A',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.VERIFIED_NAME.TOOLTIP'),
show: true,
},
{
key: 'displayNameStatus',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.LABEL'),
value: nameStatus || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.DISPLAY_NAME_STATUS.TOOLTIP'
),
show: true,
type: 'status',
},
{
key: 'qualityRating',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.LABEL'),
value: qualityRating || 'UNKNOWN',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.QUALITY_RATING.TOOLTIP'),
show: true,
type: 'quality',
},
{
key: 'messagingLimitTier',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.LABEL'),
value: messagingLimitTier || 'UNKNOWN',
tooltip: t(
'INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.MESSAGING_LIMIT_TIER.TOOLTIP'
),
show: true,
type: 'tier',
},
{
key: 'accountMode',
label: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.LABEL'),
value: accountMode || 'UNKNOWN',
tooltip: t('INBOX_MGMT.ACCOUNT_HEALTH.FIELDS.ACCOUNT_MODE.TOOLTIP'),
show: true,
type: 'mode',
},
];
});
const handleGoToSettings = () => {
const { business_id: businessId } = props.healthData || {};
if (businessId) {
// WhatsApp Business Manager URL with specific business ID and phone numbers tab
const whatsappBusinessUrl = `https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=${businessId}&tab=phone-numbers`;
window.open(whatsappBusinessUrl, '_blank');
} else {
// Fallback to general WhatsApp Business Manager if business_id is not available
const fallbackUrl = 'https://business.facebook.com/';
window.open(fallbackUrl, '_blank');
}
};
const getQualityRatingTextColor = rating =>
QUALITY_COLORS[rating] || QUALITY_COLORS.UNKNOWN;
const formatTierDisplay = tier =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.${tier}`) || tier;
const formatStatusDisplay = status =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.STATUSES.${status}`) || status;
const formatModeDisplay = mode =>
t(`INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.${mode}`) || mode;
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
</script>
<template>
<div class="gap-4 pt-8 mx-8">
<div
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
>
<div
class="flex flex-col gap-5 justify-between items-start w-full md:flex-row"
>
<div>
<span class="text-base font-medium text-n-slate-12">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.TITLE') }}
</span>
<p class="mt-1 text-sm text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.DESCRIPTION') }}
</p>
</div>
<ButtonV4
sm
solid
blue
class="flex-shrink-0"
@click="handleGoToSettings"
>
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.GO_TO_SETTINGS') }}
</ButtonV4>
</div>
<div v-if="healthData" class="grid grid-cols-1 gap-4 xs:grid-cols-2">
<div
v-for="item in healthItems"
:key="item.key"
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-11">
{{ item.label }}
</span>
<Icon
v-tooltip.top="item.tooltip"
icon="i-lucide-info"
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
/>
</div>
<div class="flex items-center">
<span
v-if="item.type === 'quality'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getQualityRatingTextColor(item.value)"
>
{{ item.value }}
</span>
<span
v-else-if="item.type === 'status'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getStatusTextColor(item.value)"
>
{{ formatStatusDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'mode'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="getModeStatusTextColor(item.value)"
>
{{ formatModeDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'tier'"
class="text-sm font-medium text-n-slate-12"
>
{{ formatTierDisplay(item.value) }}
</span>
<span v-else class="text-sm font-medium text-n-slate-12">{{
item.value
}}</span>
</div>
</div>
</div>
<div v-else class="pt-8">
<div
class="flex justify-center items-center p-8 text-center text-n-slate-11"
>
<div>
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
<p class="text-sm">{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -96,11 +96,12 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date());
},
totalHours() {
if (this.timeSlot.openAllDay) {
return 24;
}
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
return totalHours;
if (this.timeSlot.openAllDay) return '24h';
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
},
hasError() {
return !this.timeSlot.valid;
@@ -211,7 +212,7 @@ export default {
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
{{ totalHours }}
</span>
</div>
</div>
@@ -1,15 +1,26 @@
<script setup>
import Banner from 'dashboard/components-next/banner/Banner.vue';
defineProps({
actionLabel: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
});
const emit = defineEmits(['reauthorize']);
</script>
<template>
<Banner
color="ruby"
:action-label="$t('INBOX_MGMT.CLICK_TO_RECONNECT')"
:action-label="actionLabel || $t('INBOX_MGMT.CLICK_TO_RECONNECT')"
@action="emit('reauthorize')"
>
{{ $t('INBOX_MGMT.RECONNECTION_REQUIRED') }}
{{ description || $t('INBOX_MGMT.RECONNECTION_REQUIRED') }}
</Banner>
</template>
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
will be generated by adding `step` minutes to the previous one.
The list is generated by starting with a random day and adding step minutes till end of the same day.
Always includes 11:59 PM as the final slot to complete the day.
*/
const date = new Date(1970, 1, 1);
const slots = [];
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
);
date.setMinutes(date.getMinutes() + step);
}
// Always add 11:59 PM as the final slot if it's not already included
const lastSlot = '11:59 PM';
if (!slots.includes(lastSlot)) {
slots.push(lastSlot);
}
return slots;
};
@@ -7,10 +7,19 @@ import {
} from '../businessHour';
describe('#generateTimeSlots', () => {
it('returns correct number of time slots', () => {
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
it('returns correct number of time slots for 15-minute intervals', () => {
const slots = generateTimeSlots(15);
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
expect(slots.length).toStrictEqual(97);
});
it('returns correct time slots', () => {
it('returns correct number of time slots for 30-minute intervals', () => {
const slots = generateTimeSlots(30);
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
expect(slots.length).toStrictEqual(49);
});
it('returns correct time slots for 4-hour intervals', () => {
expect(generateTimeSlots(240)).toStrictEqual([
'12:00 AM',
'04:00 AM',
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
'12:00 PM',
'04:00 PM',
'08:00 PM',
'11:59 PM',
]);
});
it('always starts with 12:00 AM', () => {
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
});
it('always ends with 11:59 PM', () => {
const slots15 = generateTimeSlots(15);
const slots30 = generateTimeSlots(30);
const slots60 = generateTimeSlots(60);
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
});
it('includes 11:59 PM even when it would not be in regular intervals', () => {
const slots = generateTimeSlots(30);
expect(slots).toContain('11:59 PM');
expect(slots).toContain('11:30 PM'); // Regular interval
});
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
// Test with a step that would naturally include 11:59 PM
const slots = generateTimeSlots(1); // 1-minute intervals
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
expect(count11_59).toStrictEqual(1);
});
it('generates correct time format', () => {
const slots = generateTimeSlots(60);
expect(slots).toContain('01:00 AM');
expect(slots).toContain('12:00 PM');
expect(slots).toContain('01:00 PM');
expect(slots).toContain('11:00 PM');
});
it('handles edge case with very large step', () => {
const slots = generateTimeSlots(1440); // 24 hours
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
});
});
describe('#getTime', () => {
@@ -7,7 +7,9 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -15,6 +17,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
@@ -33,6 +36,8 @@ export default {
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
};
},
validations: {
@@ -57,6 +62,7 @@ export default {
methods: {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowedDomains = this.inbox.allowed_domains || '';
},
handleHmacFlag() {
this.updateInbox();
@@ -76,6 +82,28 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateAllowedDomains() {
this.isUpdatingAllowedDomains = true;
const sanitizedAllowedDomains = sanitizeAllowedDomains(
this.allowedDomains
);
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
allowed_domains: sanitizedAllowedDomains,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
this.allowedDomains = sanitizedAllowedDomains;
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdatingAllowedDomains = false;
}
},
async updateWhatsAppInboxAPIKey() {
try {
const payload = {
@@ -180,6 +208,30 @@ export default {
/>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
>
<div class="flex flex-col w-full max-w-3xl gap-4">
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
<div>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
>
@@ -27,6 +27,11 @@ const conversationLabels = computed(() => {
? props.conversation.labels.split(',').map(item => item.trim())
: [];
});
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: props.conversationId },
}));
</script>
<template>
@@ -36,9 +41,9 @@ const conversationLabels = computed(() => {
<div
class="flex items-center gap-2 col-span-6 px-0 py-2 text-sm tracking-[0.5] text-n-slate-12 rtl:text-right"
>
<span class="text-n-slate-12">
{{ `#${conversationId} ` }}
</span>
<router-link :to="routerParams" class="text-n-slate-12 hover:underline">
{{ `#${conversationId}` }}
</router-link>
<span class="text-n-slate-11">
{{ $t('SLA_REPORTS.WITH') }}
</span>
@@ -171,6 +171,7 @@ onMounted(() => {
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
beta
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>