Merge branch 'develop' into feature/cw-7513

This commit is contained in:
Tanmay Deep Sharma
2026-07-13 14:36:26 +05:30
55 changed files with 1547 additions and 121 deletions
@@ -1,5 +1,4 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -8,20 +7,20 @@ import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constan
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
!isOnChatwootCloud.value &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
!isOnChatwootCloud.value && Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -1,6 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -23,11 +24,17 @@ export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const { isOnChatwootCloud } = useAccount();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
if (provider === 'instagram' && isOnChatwootCloud.value) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING'));
return;
}
try {
const {
data: { url },
@@ -6,21 +6,25 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const mountComposable = ({
brandInfo,
inboxes = [],
isOnChatwootCloud = false,
} = {}) => {
const store = createStore({
modules: {
globalConfig: {
namespaced: true,
getters: {
get: () => ({}),
isOnChatwootCloud: () => isOnChatwootCloud,
},
},
accounts: {
namespaced: true,
getters: {
@@ -202,6 +206,22 @@ describe('useDetectedChannels', () => {
'line',
]);
});
it('hides Instagram from onboarding on Chatwoot Cloud', () => {
const { displayedChannels } = mountComposable({
isOnChatwootCloud: true,
brandInfo: {
socials: [
{ type: 'instagram', url: 'https://instagram.com/acme' },
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
});
describe('remainingChannels', () => {
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -27,6 +29,8 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
@@ -42,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -74,8 +80,11 @@ export default {
ColorPicker,
SelectInput,
AccountHealth,
WhatsappManualMigrationDialog,
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -107,6 +116,7 @@ export default {
isLoadingHealth: false,
healthError: null,
isRegisteringWebhook: false,
isTransferringWhatsAppToManual: false,
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
@@ -338,6 +348,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -371,10 +387,12 @@ export default {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappUnauthorized() {
// The manual migration banner supersedes the embedded-signup reauthorize flow when the feature is enabled.
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.inbox.reauthorization_required
this.inbox.reauthorization_required &&
!this.showWhatsAppManualMigration
);
},
whatsappRegistrationIncomplete() {
@@ -391,6 +409,16 @@ export default {
this.healthData.throughput?.level === 'NOT_APPLICABLE'
);
},
showWhatsAppManualMigration() {
return (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
)
);
},
widgetBuilderStorageKey() {
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
},
@@ -402,6 +430,7 @@ export default {
if (inboxChanged) {
this.syncInboxData();
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
}
}
},
@@ -412,6 +441,7 @@ export default {
this.fetchHealthData();
this.$nextTick(() => {
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
});
} else {
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
@@ -422,8 +452,52 @@ export default {
},
mounted() {
this.fetchSharedData();
this.openWhatsAppManualMigrationIfRequested();
},
methods: {
openWhatsAppManualMigrationDialog() {
this.$refs.whatsappManualMigrationDialog?.open();
},
openWhatsAppManualMigrationIfRequested() {
if (
this.showWhatsAppManualMigration &&
this.$route.query.migration === 'whatsapp_manual'
) {
this.$nextTick(() => {
this.openWhatsAppManualMigrationDialog();
});
}
},
async transferWhatsAppToManualSetup(form) {
this.isTransferringWhatsAppToManual = true;
try {
const providerConfig = { ...(this.inbox.provider_config || {}) };
delete providerConfig.source;
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...providerConfig,
phone_number_id: form.phoneNumberId,
business_account_id: form.wabaId,
api_key: form.accessToken,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_SUCCESS')
);
this.$refs.whatsappManualMigrationDialog?.close();
} catch (error) {
useAlert(
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_ERROR')
);
} finally {
this.isTransferringWhatsAppToManual = false;
}
},
async copyWebhookSecret(value) {
await copyTextToClipboard(value);
useAlert(
@@ -737,6 +811,35 @@ export default {
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<WhatsappManualMigrationBanner
v-if="showWhatsAppManualMigration"
class="mx-6 mb-6"
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -1305,6 +1408,13 @@ export default {
@register-webhook="registerWebhook"
/>
</div>
<WhatsappManualMigrationDialog
v-if="showWhatsAppManualMigration"
ref="whatsappManualMigrationDialog"
:inbox="inbox"
:is-loading="isTransferringWhatsAppToManual"
@reconnect="transferWhatsAppToManualSetup"
/>
</div>
</section>
</div>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
props: {
enableCallingOnComplete: {
type: Boolean,
default: false,
},
},
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
if (this.enableCallingOnComplete) {
try {
await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
} catch (_) {
useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
}
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -165,6 +180,7 @@ export default {
<div class="w-full mt-4">
<NextButton
:disabled="uiFlags.isCreating"
:is-loading="uiFlags.isCreating"
type="submit"
solid
@@ -1,15 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
const isInstagramConnectionRestricted = computed(() => {
return isOnChatwootCloud.value;
});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:disabled="
isRequestingAuthorization || isInstagramConnectionRestricted
"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
<Banner
v-if="isInstagramConnectionRestricted"
color="amber"
class="w-full max-w-2xl mt-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>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
<a
:href="META_RESTRICTION_STATUS_URL"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
</div>
</div>
</div>
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { useAccount } from 'dashboard/composables/useAccount';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
const isWhatsappEmbeddedSignupRestricted = computed(() => {
return isOnChatwootCloud.value;
});
const hasWhatsappAppId = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<WhatsappEmbeddedSignup
:is-disabled="isWhatsappEmbeddedSignupRestricted"
:show-restriction-alert="isWhatsappEmbeddedSignupRestricted"
:restriction-status-url="META_RESTRICTION_STATUS_URL"
/>
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
@@ -1,11 +1,11 @@
<script setup>
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import CloudWhatsapp from './CloudWhatsapp.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 />
<CloudWhatsapp enable-calling-on-complete />
</div>
</div>
</template>
@@ -7,6 +7,7 @@ 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';
@@ -17,6 +18,22 @@ 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();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
if (props.isDisabled) return;
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ 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"
:disabled="isAuthenticating || isDisabled"
:is-loading="isAuthenticating"
faded
slate
@@ -0,0 +1,46 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const emit = defineEmits(['start']);
const { t } = useI18n();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const copy = computed(() => ({
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
description: t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
),
start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
}));
</script>
<template>
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 mt-0.5 size-4 text-n-amber-11"
/>
<div class="flex flex-col gap-0.5">
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
<span>
{{ copy.description }}
<a
:href="WHATSAPP_MANUAL_MIGRATION_GUIDE_URL"
target="_blank"
rel="noopener noreferrer"
class="underline link underline-offset-2"
>
{{ copy.guide }}
</a>
</span>
</div>
</div>
</Banner>
</template>
@@ -0,0 +1,524 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['reconnect']);
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
const dialogRef = ref(null);
const currentStep = ref(0);
const buildForm = () => ({
wabaId: props.inbox.provider_config?.business_account_id || '',
phoneNumberId: props.inbox.provider_config?.phone_number_id || '',
displayPhoneNumber: props.inbox.phone_number || '',
accessToken: '',
});
const form = ref(buildForm());
const copy = computed(() => ({
eyebrow: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.EYEBROW`
),
title: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TITLE`),
close: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CLOSE`),
actionRequiredTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_TITLE`
),
actionRequiredDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_DESCRIPTION`
),
guideLink: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.GUIDE_LINK`
),
preservedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_TITLE`
),
preservedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_DESCRIPTION`
),
updatedTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_TITLE`
),
updatedDescription: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_DESCRIPTION`
),
wabaId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_ID`
),
wabaPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_PLACEHOLDER`
),
wabaHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_HELP`
),
phoneNumberId: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_ID`
),
phoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_PLACEHOLDER`
),
phoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_HELP`
),
displayPhoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER`
),
displayPhoneNumberPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_PLACEHOLDER`
),
displayPhoneNumberHelp: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_HELP`
),
accessToken: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN`
),
accessTokenPlaceholder: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN_PLACEHOLDER`
),
tokenHelpPrefix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_PREFIX`
),
tokenHelpMiddle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_MIDDLE`
),
tokenHelpSuffix: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_SUFFIX`
),
messagingPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MESSAGING_PERMISSION`
),
managementPermission: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MANAGEMENT_PERMISSION`
),
reviewTitle: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_TITLE`
),
inbox: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.INBOX`),
phoneNumber: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER`
),
notEntered: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.NOT_ENTERED`
),
verifyNotice: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.VERIFY_NOTICE`
),
back: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.BACK`),
cancel: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CANCEL`
),
continue: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CONTINUE`
),
reviewMigration: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_MIGRATION`
),
reconnect: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.RECONNECT`
),
}));
const steps = computed(() => [
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.DESCRIPTION`
),
},
{
title: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.TITLE`
),
description: t(
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.DESCRIPTION`
),
},
]);
const currentStepDetails = computed(() => steps.value[currentStep.value]);
const isFirstStep = computed(() => currentStep.value === 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
const guideUrl = WHATSAPP_MANUAL_MIGRATION_GUIDE_URL;
const hasBusinessDetails = computed(
() => form.value.wabaId.trim() && form.value.phoneNumberId.trim()
);
const hasAccessToken = computed(() => form.value.accessToken.trim());
const canContinue = computed(() => {
if (currentStep.value === 1) return hasBusinessDetails.value;
if (currentStep.value === 2) return hasAccessToken.value;
if (isLastStep.value) {
return hasBusinessDetails.value && hasAccessToken.value;
}
return true;
});
const open = () => {
currentStep.value = 0;
form.value = buildForm();
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
const goBack = () => {
if (!isFirstStep.value) currentStep.value -= 1;
};
const goNext = () => {
if (!isLastStep.value) currentStep.value += 1;
};
const reconnect = () => {
if (!canContinue.value) return;
emit('reconnect', {
wabaId: form.value.wabaId.trim(),
phoneNumberId: form.value.phoneNumberId.trim(),
accessToken: form.value.accessToken.trim(),
});
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
position="top"
:show-confirm-button="false"
:show-cancel-button="false"
overflow-y-auto
>
<div class="flex flex-col gap-5 h-[24rem]">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="mb-1 text-sm font-medium text-n-slate-11">
{{ copy.eyebrow }}
</p>
<h3 class="m-0 text-xl font-semibold text-n-slate-12">
{{ copy.title }}
</h3>
<p class="mt-2 mb-0 text-sm text-n-slate-11 min-h-[2.5rem]">
{{ replaceInstallationName(currentStepDetails.description) }}
</p>
</div>
<NextButton
v-tooltip="copy.close"
type="button"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-x"
:aria-label="copy.close"
@click="close"
/>
</div>
<div
class="grid flex-1 gap-6 overflow-y-auto min-h-0 grid-cols-[11rem,1fr]"
>
<ol class="flex flex-col p-0 m-0 list-none">
<li
v-for="(step, index) in steps"
:key="step.title"
class="relative flex gap-3"
>
<div class="relative flex flex-col items-center">
<span
class="z-10 grid text-xs font-semibold transition-colors rounded-full size-6 place-content-center"
:class="
index < currentStep
? 'bg-n-teal-9 text-white'
: index === currentStep
? 'bg-n-blue-9 text-white'
: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-container -outline-offset-1'
"
>
<Icon
v-if="index < currentStep"
icon="i-lucide-check"
class="size-3.5"
/>
<span v-else>{{ index + 1 }}</span>
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 my-1 rounded-full w-0.5"
:class="index < currentStep ? 'bg-n-teal-9' : 'bg-n-slate-4'"
/>
</div>
<span
class="text-sm leading-6 truncate min-w-0"
:class="[
index < steps.length - 1 ? 'pb-6' : '',
index === currentStep
? 'font-medium text-n-slate-12'
: 'text-n-slate-11',
]"
>
{{ step.title }}
</span>
</li>
</ol>
<div class="min-w-0">
<section v-if="currentStep === 0" class="flex flex-col gap-5">
<div
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
>
<span
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
>
<Icon icon="i-lucide-triangle-alert" class="size-4" />
</span>
<div>
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
{{ copy.actionRequiredTitle }}
</h4>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.actionRequiredDescription }}
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon icon="i-lucide-check" class="size-4 text-n-teal-11" />
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.preservedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.preservedDescription }}
</p>
</div>
<div
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-refresh-cw"
class="size-4 text-n-blue-11"
/>
<p class="m-0 text-sm font-medium text-n-slate-12">
{{ copy.updatedTitle }}
</p>
</div>
<p class="m-0 text-sm text-n-slate-11">
{{ copy.updatedDescription }}
</p>
</div>
</div>
<a
:href="guideUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-n-blue-11 hover:underline"
>
{{ copy.guideLink }}
<Icon icon="i-lucide-external-link" class="size-3.5" />
</a>
</section>
<section v-else-if="currentStep === 1" class="grid gap-4">
<div class="flex flex-col gap-1">
<Input
v-model="form.wabaId"
:label="copy.wabaId"
:placeholder="copy.wabaPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.wabaHelp }}
</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<Input
v-model="form.phoneNumberId"
:label="copy.phoneNumberId"
:placeholder="copy.phoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.phoneNumberHelp }}
</span>
</div>
<div class="flex flex-col gap-1">
<Input
v-model="form.displayPhoneNumber"
disabled
:label="copy.displayPhoneNumber"
:placeholder="copy.displayPhoneNumberPlaceholder"
/>
<span class="text-xs leading-5 text-n-slate-11">
{{ copy.displayPhoneNumberHelp }}
</span>
</div>
</div>
</section>
<section v-else-if="currentStep === 2" class="grid gap-4">
<TextArea
v-model="form.accessToken"
:label="copy.accessToken"
:placeholder="copy.accessTokenPlaceholder"
auto-height
min-height="6rem"
max-height="12rem"
/>
<div
class="flex gap-3 p-3 border rounded-xl bg-n-blue-3 border-n-blue-4 text-n-blue-11"
>
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4 mt-0.5" />
<p class="m-0 text-sm">
{{ copy.tokenHelpPrefix }}
<code>{{ copy.messagingPermission }}</code>
{{ copy.tokenHelpMiddle }}
<code>{{ copy.managementPermission }}</code>
{{ copy.tokenHelpSuffix }}
</p>
</div>
</section>
<section
v-else
class="flex flex-col gap-4 p-4 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
>
<h4 class="m-0 text-base font-medium text-n-slate-12">
{{ copy.reviewTitle }}
</h4>
<dl class="grid gap-3 m-0 sm:grid-cols-2">
<div>
<dt class="text-xs text-n-slate-11">{{ copy.inbox }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ inbox.name }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumber }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.displayPhoneNumber || inbox.phone_number }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">{{ copy.wabaId }}</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.wabaId || copy.notEntered }}
</dd>
</div>
<div>
<dt class="text-xs text-n-slate-11">
{{ copy.phoneNumberId }}
</dt>
<dd class="m-0 text-sm font-medium text-n-slate-12">
{{ form.phoneNumberId || copy.notEntered }}
</dd>
</div>
</dl>
<div
class="flex gap-3 p-3 border rounded-lg bg-n-alpha-2 border-n-weak text-n-slate-11"
>
<Icon
icon="i-lucide-shield-check"
class="flex-shrink-0 size-4 mt-0.5 text-n-teal-11"
/>
<p class="m-0 text-sm">
{{ replaceInstallationName(copy.verifyNotice) }}
</p>
</div>
</section>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<NextButton
type="button"
variant="faded"
color="slate"
:disabled="isFirstStep"
@click="goBack"
>
{{ copy.back }}
</NextButton>
<div class="flex items-center gap-2">
<NextButton
type="button"
variant="ghost"
color="slate"
@click="close"
>
{{ copy.cancel }}
</NextButton>
<NextButton
v-if="!isLastStep"
type="button"
:disabled="!canContinue"
@click="goNext"
>
{{ currentStep === 2 ? copy.reviewMigration : copy.continue }}
</NextButton>
<NextButton
v-else
type="button"
color="teal"
:disabled="!canContinue"
:is-loading="isLoading"
@click="reconnect"
>
{{ copy.reconnect }}
</NextButton>
</div>
</div>
</template>
</Dialog>
</template>
@@ -10,7 +10,6 @@ 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 {
@@ -22,7 +21,6 @@ export default {
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -39,7 +37,6 @@ export default {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
@@ -53,9 +50,6 @@ export default {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
},
@@ -166,11 +160,6 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleReconfigure() {
if (this.$refs.whatsappReauth) {
await this.$refs.whatsappReauth.requestAuthorization();
}
},
async syncTemplates() {
this.isSyncingTemplates = true;
try {
@@ -362,22 +351,17 @@ export default {
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsFieldSection
v-if="whatsappAppId"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
>
<div class="flex flex-col gap-1 items-start">
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<template v-else-if="!isEmbeddedSignupWhatsApp">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
@@ -435,12 +419,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>