Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m

This commit is contained in:
Sony Mathew
2026-07-21 18:09:06 +05:30
committed by GitHub
1072 changed files with 49075 additions and 1593 deletions
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { until } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useCallHistoryStore } from 'dashboard/stores/callHistory';
import CallListItem from 'dashboard/components-next/Calls/CallListItem.vue';
import CallsEmptyState from 'dashboard/components-next/Calls/CallsEmptyState.vue';
import CallsFilterBar from 'dashboard/components-next/Calls/CallsFilterBar.vue';
import { CALL_ACTIVITY_PARAMS } from 'dashboard/components-next/Calls/constants';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const RESULTS_PER_PAGE = 25;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const callHistoryStore = useCallHistoryStore();
const inboxes = useMapGetter('inboxes/getInboxes');
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const agents = useMapGetter('agents/getVerifiedAgents');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
// CallFinder scopes non-admins to their own accepted calls, so the assignee
// filter is only meaningful for admins; everyone else defaults to themselves.
const { isAdmin } = useAdmin();
const voiceInboxes = computed(() => inboxes.value.filter(isVoiceCallEnabled));
const isVoiceEnabled = computed(
() =>
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CHANNEL_VOICE
) && voiceInboxes.value.length > 0
);
const calls = computed(() => callHistoryStore.records);
const meta = computed(() => callHistoryStore.meta);
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
const accountUiFlags = useMapGetter('accounts/getUIFlags');
const isInitializing = ref(true);
// Filters are seeded from the URL so a shared link restores the same view.
const activity = ref(
CALL_ACTIVITY_PARAMS[route.query.activity] ? route.query.activity : null
);
const assigneeId = ref(
isAdmin.value ? Number(route.query.assignee_id) || null : currentUserId.value
);
const inboxId = ref(Number(route.query.inbox_id) || null);
const currentPage = ref(Number(route.query.page) || 1);
const syncFiltersToUrl = () => {
router.replace({
query: {
...(activity.value && { activity: activity.value }),
...(isAdmin.value &&
assigneeId.value && { assignee_id: assigneeId.value }),
...(inboxId.value && { inbox_id: inboxId.value }),
...(currentPage.value > 1 && { page: currentPage.value }),
},
});
};
const fetchCalls = async () => {
syncFiltersToUrl();
try {
await callHistoryStore.fetchCalls({
page: currentPage.value,
...(CALL_ACTIVITY_PARAMS[activity.value] || {}),
...(assigneeId.value ? { agent_id: assigneeId.value } : {}),
...(inboxId.value ? { inbox_id: inboxId.value } : {}),
});
} catch (error) {
useAlert(error.message);
}
};
watch([activity, assigneeId, inboxId], () => {
currentPage.value = 1;
fetchCalls();
});
const onPageChange = page => {
currentPage.value = page;
fetchCalls();
};
onMounted(async () => {
try {
await Promise.all([
store.dispatch('inboxes/get'),
until(() => accountUiFlags.value.isFetchingItem).toBe(false),
]);
if (!isVoiceEnabled.value) return;
// Only admins see the assignee filter, so only they need the agent list.
if (isAdmin.value) store.dispatch('agents/get');
await fetchCalls();
} finally {
isInitializing.value = false;
}
});
</script>
<template>
<div
v-if="isInitializing"
class="flex items-center justify-center w-full h-full bg-n-surface-1"
>
<Spinner :size="24" />
</div>
<CallsEmptyState v-else-if="!isVoiceEnabled" />
<section
v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
>
<header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full">
<h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }}
</h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full">
<div v-if="isFetching" class="flex items-center justify-center py-16">
<Spinner :size="24" />
</div>
<div
v-else-if="!calls.length"
class="flex items-center justify-center py-16"
>
<span class="text-base text-n-slate-11">
{{ t('CALLS_PAGE.EMPTY_STATE') }}
</span>
</div>
<template v-else>
<CallListItem v-for="call in calls" :key="call.id" :call="call" />
</template>
</div>
</main>
<footer v-if="calls.length" class="sticky bottom-0 shrink-0">
<PaginationFooter
:current-page="currentPage"
:total-items="meta.count"
:items-per-page="RESULTS_PER_PAGE"
@update:current-page="onPageChange"
/>
</footer>
</section>
</template>
@@ -0,0 +1,22 @@
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import {
CONVERSATION_PERMISSIONS,
ROLES,
} from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CallsIndex from './pages/CallsIndex.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/calls'),
name: 'calls_dashboard_index',
component: CallsIndex,
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -27,19 +27,49 @@ const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const isFetching = ref(false);
// Increments on every fetch so a response (or retry) from a superseded
// range/assistant can't clobber the latest request's state.
let fetchToken = 0;
let abortController = null;
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
fetchToken += 1;
const token = fetchToken;
abortController?.abort();
abortController = new AbortController();
const { signal } = abortController;
stats.value = null;
isFetching.value = true;
const requestStats = () =>
CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
signal,
});
stats.value = data;
let data = null;
try {
({ data } = await requestStats());
} catch {
stats.value = null;
// One silent retry before giving up, unless the request was aborted.
try {
if (token === fetchToken && !signal.aborted)
({ data } = await requestStats());
} catch {
data = null;
}
}
if (token !== fetchToken || signal.aborted) return;
stats.value = data;
isFetching.value = false;
};
onUnmounted(() => abortController?.abort());
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
@@ -156,7 +186,7 @@ const closeDrilldown = () => {
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<WelcomeCard :range="selectedRange" :stats="stats" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
@@ -169,7 +199,8 @@ const closeDrilldown = () => {
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
:loading="isFetching"
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
@click="openDrilldown(metric)"
/>
</div>
@@ -1,6 +1,7 @@
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as callRoutes } from './calls/routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
@@ -25,6 +26,7 @@ export default {
...inboxRoutes,
...conversation.routes,
...settings.routes,
...callRoutes,
...contactRoutes,
...companyRoutes,
...searchRoutes,
@@ -17,7 +17,8 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!isOnChatwootCloud.value &&
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
@@ -7,7 +7,7 @@ vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('dashboard/composables/useAccount', () => ({
useAccount: () => ({ isCloudFeatureEnabled: () => false }),
useAccount: () => ({ isCloudFeatureEnabled: () => true }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const enabledFeatures = computed(() => currentAccount.value?.features || {});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
@@ -105,10 +105,6 @@ const channelList = computed(() => {
return channels;
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
@@ -116,10 +112,6 @@ const initChannelAuth = channel => {
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
@@ -390,6 +390,11 @@ export default {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
(!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
)) &&
this.inbox.reauthorization_required
);
},
@@ -8,12 +8,12 @@ import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -24,10 +24,6 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
@@ -41,6 +37,15 @@ const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const shouldShowWhatsappEmbeddedSignup = computed(() => {
return (
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value &&
(!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
);
});
const availableProviders = computed(() => [
{
key: PROVIDER_TYPES.WHATSAPP,
@@ -67,7 +72,8 @@ const selectProvider = providerValue => {
const shouldShowCloudWhatsapp = provider => {
return (
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
(provider === PROVIDER_TYPES.WHATSAPP &&
!shouldShowWhatsappEmbeddedSignup.value)
);
};
@@ -102,17 +108,8 @@ const handleManualLinkClick = () => {
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<!-- Show embedded signup if app ID is configured -->
<div
v-if="
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<div v-if="shouldShowWhatsappEmbeddedSignup">
<WhatsappEmbeddedSignup />
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -7,7 +7,6 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -18,22 +17,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
isDisabled: {
type: Boolean,
default: false,
},
showRestrictionAlert: {
type: Boolean,
default: false,
},
restrictionStatusUrl: {
type: String,
default: '',
},
restrictionWarningText: {
type: String,
default: '',
},
});
const store = useStore();
@@ -98,8 +81,6 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -193,33 +174,9 @@ const launchEmbeddedSignup = async () => {
</I18nT>
</div>
<Banner v-if="showRestrictionAlert" color="amber" class="w-full mb-6">
<div class="flex items-start gap-3 text-left">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{
restrictionWarningText ||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
}}
<a
v-if="restrictionStatusUrl"
:href="restrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating || isDisabled"
:disabled="isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({
senderNameType: {
@@ -22,6 +23,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [
{
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: 'Chatwoot',
businessName: replaceInstallationName('Chatwoot'),
email: '<support@yourbusiness.com>',
},
},
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: keyOption.preview.businessName;
: props.businessName || keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
@@ -56,6 +56,7 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
@@ -65,7 +66,9 @@ export default {
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
this.isOnChatwootCloud
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
)
);
},