refactor(onboarding): extract whatsapp and facebook channel connect into reusable composables (#14619)

Extracts the Meta JS SDK logic for the WhatsApp embedded-signup and
Facebook Page connect flows out of their settings components into two
reusable composables. No behavior change — the settings inbox-creation
flows work exactly as before; this just makes the SDK orchestration
reusable (an upcoming onboarding PR consumes them) and adds unit
coverage.

## What changed
- `useWhatsappEmbeddedSignup` — owns the embedded-signup popup (SDK
load, FB.login, and the auth-code / postMessage race).
`WhatsappEmbeddedSignup.vue` now consumes it.
- `useFacebookPageConnect` — owns FB.login (page scopes) + page fetch,
with SDK preload split from login so the popup opens within the click's
activation window. `Facebook.vue` now consumes it.
- Adds unit specs for both composables.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14649
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
This commit is contained in:
Shivam Mishra
2026-06-08 13:13:20 +05:30
committed by GitHub
parent 73ba0b26e5
commit 78a6b2457d
6 changed files with 575 additions and 250 deletions
@@ -0,0 +1,148 @@
import { useFacebookPageConnect } from '../useFacebookPageConnect';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock('dashboard/composables/store', () => ({ useMapGetter: vi.fn() }));
vi.mock('dashboard/api/channels', () => ({
default: { fetchFacebookPages: vi.fn() },
}));
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({ setupFacebookSdk: vi.fn() })
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const ACCOUNT_ID = 42;
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'page-token-1' },
{ id: 'p2', name: 'Page Two', access_token: 'page-token-2', exists: true },
];
const pagesResponse = {
data: { data: { page_details: PAGES, user_access_token: 'long-token' } },
};
// FB.login invokes its callback with the given response.
const stubLogin = response => {
window.FB = { login: vi.fn(callback => callback(response)) };
};
const connected = {
status: 'connected',
authResponse: { accessToken: 'user-token' },
};
describe('useFacebookPageConnect', () => {
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = { fbAppId: 'fb-app', fbApiVersion: 'v22.0' };
useMapGetter.mockReturnValue({ value: ACCOUNT_ID });
setupFacebookSdk.mockResolvedValue();
ChannelApi.fetchFacebookPages.mockResolvedValue(pagesResponse);
stubLogin(connected);
});
it('resolves the user token and pages on a connected login', async () => {
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toEqual({
userAccessToken: 'long-token',
pages: PAGES,
});
expect(setupFacebookSdk).toHaveBeenCalledWith('fb-app', 'v22.0');
expect(ChannelApi.fetchFacebookPages).toHaveBeenCalledWith(
'user-token',
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope: expect.stringContaining('pages_show_list'),
});
});
it('resolves null when the user is not authorized', async () => {
stubLogin({ status: 'not_authorized' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
expect(ChannelApi.fetchFacebookPages).not.toHaveBeenCalled();
});
it('resolves null on an unknown login status', async () => {
stubLogin({ status: 'unknown' });
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).resolves.toBeNull();
});
it('rejects when fetching pages fails', async () => {
ChannelApi.fetchFacebookPages.mockRejectedValue(new Error('fetch failed'));
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('fetch failed');
});
it('rejects when the SDK fails to load', async () => {
const error = new Error('script load failed');
error.name = 'ScriptLoaderError';
setupFacebookSdk.mockRejectedValue(error);
const { loginAndFetchPages } = useFacebookPageConnect();
await expect(loginAndFetchPages()).rejects.toThrow('script load failed');
});
it('ignores a second call while a run is in flight', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { loginAndFetchPages } = useFacebookPageConnect();
const first = loginAndFetchPages();
const second = loginAndFetchPages();
await expect(second).resolves.toBeNull();
pending.resolve(pagesResponse);
await first;
expect(window.FB.login).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating across a run', async () => {
const pending = createDeferred();
ChannelApi.fetchFacebookPages.mockReturnValue(pending.promise);
const { isAuthenticating, loginAndFetchPages } = useFacebookPageConnect();
expect(isAuthenticating.value).toBe(false);
const result = loginAndFetchPages();
await flushPromises();
expect(isAuthenticating.value).toBe(true);
pending.resolve(pagesResponse);
await result;
expect(isAuthenticating.value).toBe(false);
});
it('preloads the SDK once and reuses it for login', async () => {
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
preloadSdk();
preloadSdk();
await loginAndFetchPages();
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,208 @@
import { useWhatsappEmbeddedSignup } from '../useWhatsappEmbeddedSignup';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
vi.mock(
'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils',
() => ({
setupFacebookSdk: vi.fn(),
initWhatsAppEmbeddedSignup: vi.fn(),
createMessageHandler: vi.fn(),
isValidBusinessData: vi.fn(data =>
Boolean(data && data.business_id && data.waba_id)
),
})
);
const flushPromises = () =>
new Promise(resolve => {
setTimeout(resolve, 0);
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const VALID_BUSINESS = {
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
};
describe('useWhatsappEmbeddedSignup', () => {
// The mocked createMessageHandler captures the callback the composable
// registers, so tests can simulate Meta's WA_EMBEDDED_SIGNUP postMessages
// directly without the window-event + origin plumbing (that is covered by
// the utils' own tests).
let signupCallback;
let registeredListener;
const emit = data => signupCallback(data);
beforeEach(() => {
vi.clearAllMocks();
window.chatwootConfig = {
whatsappAppId: 'app-id',
whatsappConfigurationId: 'config-id',
whatsappApiVersion: 'v22.0',
};
setupFacebookSdk.mockResolvedValue();
createMessageHandler.mockImplementation(callback => {
signupCallback = callback;
registeredListener = () => {};
return registeredListener;
});
});
it('resolves credentials when the auth code arrives before the business data', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises(); // SDK setup + FB.login resolve the code first
emit({ event: 'FINISH', data: VALID_BUSINESS });
await expect(result).resolves.toEqual({
code: 'auth-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
expect(setupFacebookSdk).toHaveBeenCalledWith('app-id', 'v22.0');
expect(initWhatsAppEmbeddedSignup).toHaveBeenCalledWith('config-id');
});
it('resolves credentials when the business data arrives before the auth code', async () => {
const code = createDeferred();
initWhatsAppEmbeddedSignup.mockReturnValue(code.promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
// Business data lands first, while FB.login is still pending.
emit({
event: 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING',
data: VALID_BUSINESS,
});
code.resolve('late-code');
await expect(result).resolves.toEqual({
code: 'late-code',
business_id: 'biz-1',
waba_id: 'waba-1',
phone_number_id: 'phone-1',
});
});
it('defaults phone_number_id to an empty string when absent', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
await flushPromises();
emit({
event: 'FINISH',
data: { business_id: 'biz-1', waba_id: 'waba-1' },
});
await expect(result).resolves.toMatchObject({ phone_number_id: '' });
});
it('resolves null when FB.login is cancelled', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('Login cancelled'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).resolves.toBeNull();
});
it('resolves null on a CANCEL event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'CANCEL' });
await expect(result).resolves.toBeNull();
});
it('rejects with the Meta error message on an error event', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'error', error_message: 'WABA not eligible' });
await expect(result).rejects.toThrow('WABA not eligible');
});
it('rejects when the business data is invalid', async () => {
initWhatsAppEmbeddedSignup.mockReturnValue(createDeferred().promise);
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const result = runEmbeddedSignup();
emit({ event: 'FINISH', data: { business_id: 'biz-1' } }); // no waba_id
await expect(result).rejects.toThrow('Invalid business data');
});
it('rejects when the SDK or login fails for a non-cancel reason', async () => {
initWhatsAppEmbeddedSignup.mockRejectedValue(new Error('popup blocked'));
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
await expect(runEmbeddedSignup()).rejects.toThrow('popup blocked');
});
it('ignores a second call while a run is in flight', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const first = runEmbeddedSignup();
const second = runEmbeddedSignup();
await expect(second).resolves.toBeNull();
// Let the first run finish so it doesn't leak into the next test.
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await first;
expect(setupFacebookSdk).toHaveBeenCalledTimes(1);
});
it('toggles isAuthenticating and removes the listener once settled', async () => {
initWhatsAppEmbeddedSignup.mockResolvedValue('auth-code');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
expect(isAuthenticating.value).toBe(false);
const result = runEmbeddedSignup();
expect(isAuthenticating.value).toBe(true);
await flushPromises();
emit({ event: 'FINISH', data: VALID_BUSINESS });
await result;
expect(isAuthenticating.value).toBe(false);
expect(removeSpy).toHaveBeenCalledWith('message', registeredListener);
removeSpy.mockRestore();
});
});
@@ -0,0 +1,81 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
// interactive step (a user can manage several pages).
//
// Split into preloadSdk() + loginAndFetchPages() for popup safety: FB.login
// opens a popup and needs the click's transient activation. Preloading the SDK
// when the picker opens means the click-time `await` resolves within that
// activation window; a cold load resolves on the script's `load` task seconds
// later, after activation has expired, and the popup gets blocked.
export function useFacebookPageConnect() {
const accountId = useMapGetter('getCurrentAccountId');
const isAuthenticating = ref(false);
let sdkSetupPromise = null;
// Idempotent — call this when the picker UI opens. A failed load clears the
// cache so a later attempt can retry instead of being stuck on a rejection.
const preloadSdk = () => {
if (!sdkSetupPromise) {
sdkSetupPromise = setupFacebookSdk(
window.chatwootConfig?.fbAppId,
window.chatwootConfig?.fbApiVersion
).catch(error => {
sdkSetupPromise = null;
throw error;
});
}
return sdkSetupPromise;
};
// FB.login never rejects; resolve the user access token on success and null
// for any other status (closed popup, not_authorized, unknown).
const login = () =>
new Promise(resolve => {
window.FB.login(
response => {
resolve(
response.status === 'connected'
? response.authResponse?.accessToken || null
: null
);
},
{ scope: FB_PAGE_SCOPES }
);
});
// Resolves { userAccessToken, pages } on success, null when the user cancels,
// and rejects on SDK-load or page-fetch failure (the caller maps it to UI).
const loginAndFetchPages = async () => {
if (isAuthenticating.value) return null;
isAuthenticating.value = true;
try {
await preloadSdk();
const token = await login();
if (!token) return null;
const response = await ChannelApi.fetchFacebookPages(
token,
accountId.value
);
const { page_details: pages, user_access_token: userAccessToken } =
response.data.data;
return { userAccessToken, pages };
} finally {
isAuthenticating.value = false;
}
};
return { isAuthenticating, preloadSdk, loginAndFetchPages };
}
@@ -0,0 +1,96 @@
import { ref } from 'vue';
import {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Drives Meta's WhatsApp embedded-signup popup (Facebook JS SDK). FB.login()
// resolves an auth `code` while the WABA identifiers (waba_id, phone_number_id)
// arrive separately over a postMessage event — order isn't guaranteed, so we
// hold both and resolve once both are present.
//
// `runEmbeddedSignup` returns the signup credentials; the caller exchanges them
// for an inbox via `inboxes/createWhatsAppEmbeddedSignup` and owns its own UX
// (alerts, navigation, etc). Resolves `null` when the user cancels the popup;
// rejects on SDK load or signup errors. The window listener is scoped to a
// single run, so this is safe to call from anywhere without lifecycle wiring.
export function useWhatsappEmbeddedSignup() {
const isAuthenticating = ref(false);
const runEmbeddedSignup = () => {
if (isAuthenticating.value) return Promise.resolve(null);
isAuthenticating.value = true;
return new Promise((resolve, reject) => {
let authCode = null;
let businessData = null;
let settled = false;
let messageHandler;
const settle = (fn, value) => {
if (settled) return;
settled = true;
window.removeEventListener('message', messageHandler);
isAuthenticating.value = false;
fn(value);
};
// Both the auth code and the business data arrive asynchronously and in
// no fixed order; only resolve once we're holding both.
const resolveIfReady = () => {
if (!authCode || !businessData) return;
settle(resolve, {
code: authCode,
business_id: businessData.business_id,
waba_id: businessData.waba_id,
phone_number_id: businessData.phone_number_id || '',
});
};
messageHandler = createMessageHandler(data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
if (!isValidBusinessData(data.data)) {
settle(reject, new Error('Invalid business data'));
return;
}
businessData = data.data;
resolveIfReady();
} else if (data.event === 'CANCEL') {
settle(resolve, null);
} else if (data.event === 'error') {
settle(reject, new Error(data.error_message || 'Signup error'));
}
});
window.addEventListener('message', messageHandler);
(async () => {
try {
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
authCode = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
resolveIfReady();
} catch (error) {
// FB.login() rejects with 'Login cancelled' when the user dismisses
// the popup — treat it as a cancel rather than an error.
if (error.message === 'Login cancelled') {
settle(resolve, null);
} else {
settle(reject, error);
}
}
})();
});
};
return { isAuthenticating, runEmbeddedSignup };
}
@@ -1,20 +1,16 @@
<script>
/* eslint-env browser */
/* global FB */
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
export default {
@@ -25,11 +21,12 @@ export default {
ComboBox,
},
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
const { preloadSdk, loginAndFetchPages } = useFacebookPageConnect();
return {
accountId,
replaceInstallationName,
preloadSdk,
loginAndFetchPages,
v$: useVuelidate(),
};
},
@@ -37,9 +34,7 @@ export default {
return {
isCreating: false,
hasError: false,
omniauth_token: '',
user_access_token: '',
channel: 'facebook',
selectedPage: { name: null, id: null },
pageName: '',
pageList: [],
@@ -78,24 +73,29 @@ export default {
},
mounted() {
window.fbAsyncInit = this.runFBInit;
// Warm the SDK so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
this.preloadSdk();
},
methods: {
async startLogin() {
this.hasLoginStarted = true;
try {
// this will load the SDK in a promise, and resolve it when the sdk is loaded
// in case the SDK is already present, it will resolve immediately
await this.loadFBsdk();
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
this.tryFBlogin(); // make an attempt to login
const result = await this.loginAndFetchPages();
if (!result) {
// Cancelled popup / not authorized — surface a generic auth error.
this.hasError = true;
this.errorStateMessage = this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH');
this.errorStateDescription = '';
return;
}
this.pageList = result.pages;
this.user_access_token = result.userAccessToken;
} catch (error) {
if (error.name === 'ScriptLoaderError') {
// if the error was related to script loading, we show a toast
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
@@ -114,81 +114,6 @@ export default {
this.v$.selectedPage.$touch();
},
initChannelAuth(channel) {
if (channel === 'facebook') {
this.loadFBsdk();
}
},
runFBInit() {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
},
async loadFBsdk() {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-jssdk',
});
},
tryFBlogin() {
FB.login(
response => {
this.hasError = false;
if (response.status === 'connected') {
this.fetchPages(response.authResponse.accessToken);
} else if (response.status === 'not_authorized') {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is logged into Facebook, but not your app.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED'
);
this.errorStateDescription = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_UNAUTHORIZED_HELP'
);
} else {
// eslint-disable-next-line no-console
console.error('FACEBOOK AUTH ERROR', response);
this.hasError = true;
// The person is not logged into Facebook, so we're not sure if
// they are logged into this app or not.
this.errorStateMessage = this.$t(
'INBOX_MGMT.DETAILS.ERROR_FB_AUTH'
);
this.errorStateDescription = '';
}
},
{
scope:
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages',
}
);
},
async fetchPages(_token) {
try {
const response = await ChannelApi.fetchFacebookPages(
_token,
this.accountId
);
const {
data: { data },
} = response;
this.pageList = data.page_details;
this.user_access_token = data.user_access_token;
} catch (error) {
// Ignore error
}
},
channelParams() {
return {
user_access_token: this.user_access_token,
@@ -1,21 +1,16 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n, I18nT } from 'vue-i18n';
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 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 {
setupFacebookSdk,
initWhatsAppEmbeddedSignup,
createMessageHandler,
isValidBusinessData,
} from './whatsapp/utils';
const props = defineProps({
enableCallingOnComplete: {
@@ -27,15 +22,10 @@ const props = defineProps({
const store = useStore();
const router = useRouter();
const { t } = useI18n();
const { isAuthenticating, runEmbeddedSignup } = useWhatsappEmbeddedSignup();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
const benefits = computed(() => [
{
@@ -54,25 +44,6 @@ const benefits = computed(() => [
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
};
const enableCallingForInbox = async inboxId => {
try {
await InboxesAPI.enableWhatsappCalling(inboxId);
@@ -86,14 +57,12 @@ const enableCallingForInbox = async inboxId => {
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',
@@ -104,7 +73,6 @@ const handleSignupSuccess = async inboxData => {
});
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
@@ -112,12 +80,21 @@ const handleSignupSuccess = async inboxData => {
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
const launchEmbeddedSignup = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch (error) {
useAlert(
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR')
);
return;
}
// Resolves null when the user dismisses the Meta popup.
if (!credentials) {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
return;
}
@@ -125,130 +102,20 @@ const completeSignupFlow = async businessDataParam => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
const params = {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam?.phone_number_id || '',
};
const responseData = await store.dispatch(
const inboxData = await store.dispatch(
'inboxes/createWhatsAppEmbeddedSignup',
params
credentials
);
authCode.value = null;
handleSignupSuccess(responseData);
await handleSignupSuccess(inboxData);
} catch (error) {
const errorMessage =
isProcessing.value = false;
useAlert(
parseAPIErrorResponse(error) ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
handleSignupError({ error: errorMessage });
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE')
);
}
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (
data.event === 'FINISH' ||
data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING'
) {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
businessData.value = businessDataLocal;
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(businessDataLocal);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation();
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const handleSignupMessage = createMessageHandler(handleEmbeddedSignupData);
const launchEmbeddedSignup = async () => {
try {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
await setupFacebookSdk(
window.chatwootConfig?.whatsappAppId,
window.chatwootConfig?.whatsappApiVersion
);
fbSdkLoaded.value = true;
const code = await initWhatsAppEmbeddedSignup(
window.chatwootConfig?.whatsappConfigurationId
);
authCode.value = code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} catch (error) {
if (error.message === 'Login cancelled') {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
} else {
handleSignupError({
error:
error.message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
}
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
const initialize = () => {
setupMessageListener();
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>