Merge branch 'develop' into feat/app-store-reviews

This commit is contained in:
Muhsin Keloth
2026-06-02 21:13:24 +04:00
committed by GitHub
729 changed files with 20146 additions and 2805 deletions
@@ -17,7 +17,7 @@ const CommandBar = defineAsyncComponent(
);
const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
() => import('dashboard/components-next/call/FloatingCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
@@ -25,6 +25,7 @@ const props = defineProps({
const emit = defineEmits([
'clearSelection',
'assignLabels',
'removeLabels',
'toggleAll',
'deleteSelected',
]);
@@ -72,6 +73,10 @@ const selectionModel = computed({
const handleAssignLabels = labels => {
emit('assignLabels', labels);
};
const handleRemoveLabels = labels => {
emit('removeLabels', labels);
};
</script>
<template>
@@ -103,6 +108,13 @@ const handleAssignLabels = labels => {
:disabled="!selectedCount"
@assign="handleAssignLabels"
/>
<BulkLabelActions
type="contact"
action="remove"
:is-loading="isLoading"
:disabled="!selectedCount"
@remove="handleRemoveLabels"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Policy :permissions="['administrator']">
<Button
@@ -351,6 +351,28 @@ const assignLabels = async labels => {
}
};
const removeLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
labels: { remove: labels },
});
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const deleteContacts = async () => {
if (!selectedContactIds.value.length) {
return;
@@ -514,6 +536,7 @@ onMounted(async () => {
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
@remove-labels="removeLabels"
@delete-selected="openBulkDeleteDialog"
/>
<ContactEmptyState
@@ -56,7 +56,10 @@ export default {
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
...mapGetters({
uiFlags: 'contacts/getUIFlags',
currentChat: 'getSelectedChat',
}),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
@@ -305,11 +308,12 @@ export default {
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
:conversation-id="currentChat?.id"
icon="i-lucide-phone"
sm
faded
slate
:tooltip-label="$t('CONTACT_PANEL.CALL')"
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
@@ -28,7 +28,7 @@ import {
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -195,6 +195,12 @@ const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const normalizeWebsiteUrl = raw => {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
@@ -211,9 +217,27 @@ const handleSubmit = async () => {
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
website.value = normalizeWebsiteUrl(website.value);
isSubmitting.value = true;
try {
await updateAccount({
await finishOnboarding({
name: accountName.value,
locale: locale.value,
website: website.value,
@@ -224,20 +248,11 @@ const handleSubmit = async () => {
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
fields_changed: fieldsChanged,
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
@@ -7,6 +7,7 @@ import Api from './channels/Api.vue';
import Email from './channels/Email.vue';
import Sms from './channels/Sms.vue';
import Whatsapp from './channels/Whatsapp.vue';
import WhatsappCall from './channels/WhatsappCall.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
@@ -22,6 +23,7 @@ const channelViewList = {
email: Email,
sms: Sms,
whatsapp: Whatsapp,
whatsapp_call: WhatsappCall,
line: Line,
telegram: Telegram,
instagram: Instagram,
@@ -101,6 +101,13 @@ const channelList = computed(() => {
icon: 'i-woot-voice',
});
channels.push({
key: 'whatsapp_call',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.DESCRIPTION'),
icon: 'i-woot-whatsapp',
});
return channels;
});
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -48,6 +49,7 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -249,6 +251,22 @@ export default {
];
}
if (
this.isAWhatsAppCloudChannel &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'calls-configuration',
name: this.$t('INBOX_MGMT.TABS.CALLS'),
},
];
}
if (this.isAnAppStoreChannel) {
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
@@ -1275,6 +1293,12 @@ export default {
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'calls-configuration'"
class="mx-6 max-w-4xl"
>
<WhatsappCallingPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -0,0 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
</script>
<template>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div class="px-6 py-5 rounded-2xl border border-n-weak">
<WhatsappEmbeddedSignup enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
@@ -16,6 +17,13 @@ import {
isValidBusinessData,
} from './whatsapp/utils';
const props = defineProps({
enableCallingOnComplete: {
type: Boolean,
default: false,
},
});
const store = useStore();
const router = useRouter();
const { t } = useI18n();
@@ -65,11 +73,27 @@ const handleSignupCancellation = () => {
isAuthenticating.value = false;
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
const enableCallingForInbox = async inboxId => {
try {
await InboxesAPI.enableWhatsappCalling(inboxId);
} catch (_) {
useAlert(
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED')
);
}
};
const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
if (props.enableCallingOnComplete) {
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
);
await enableCallingForInbox(inboxData.id);
}
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => {
},
});
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
@@ -0,0 +1,160 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextButton,
TextArea,
Spinner,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
};
},
computed: {
phoneNumber() {
return (
this.inbox.provider_config?.phone_number || this.inbox.phone_number
);
},
},
watch: {
'inbox.provider_config.calling_enabled'(val) {
this.callingEnabled = val || false;
},
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
},
methods: {
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
this.callingEnabled = newValue;
this.isTogglingCalling = true;
try {
if (newValue) {
await InboxesAPI.enableWhatsappCalling(this.inbox.id);
} else {
await InboxesAPI.disableWhatsappCalling(this.inbox.id);
}
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.callingEnabled = previousValue;
const fallbackKey = newValue
? 'INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'
: 'INBOX_MGMT.EDIT.API.ERROR_MESSAGE';
useAlert(this.$t(fallbackKey));
} finally {
this.isTogglingCalling = false;
}
},
async updateCallingSettings() {
this.isUpdating = true;
try {
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
call_permission_request_body:
this.permissionRequestBody.trim() || null,
},
},
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
const message =
error?.response?.data?.message ||
this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE');
useAlert(message);
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingCalling }"
>
<SettingsToggleSection
:model-value="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
:hide-toggle="isTogglingCalling"
@update:model-value="handleCallingToggle"
>
<template v-if="isTogglingCalling" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<template v-if="callingEnabled">
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
>
<woot-code :script="phoneNumber" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.LABEL')"
:help-text="
$t('INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.HELP_TEXT')
"
>
<TextArea
v-model="permissionRequestBody"
:placeholder="
$t(
'INBOX_MGMT.WHATSAPP_CALLING.PERMISSION_REQUEST_BODY.PLACEHOLDER'
)
"
auto-height
resize
/>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
/>
<div>
<NextButton
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateCallingSettings"
/>
</div>
</template>
</div>
</template>