[verified] Remove automatic signup enrichment
This commit is contained in:
@@ -59,7 +59,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.unread_count_changed':
|
||||
this.onConversationUnreadCountChanged,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.accepted': this.onVoiceCallAccepted,
|
||||
@@ -338,10 +337,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -16,9 +16,7 @@ import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
|
||||
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
COMPANY_SIZE_OPTIONS,
|
||||
INDUSTRY_OPTIONS,
|
||||
@@ -75,21 +73,6 @@ const v$ = useVuelidate(validationRules, {
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const { isEnriching, getChangedFields } = useAccountEnrichment({
|
||||
locale,
|
||||
website,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
|
||||
return (square || logos[0])?.url || '';
|
||||
});
|
||||
|
||||
const languageOptions = computed(() => {
|
||||
const langs = [...(enabledLanguages || [])];
|
||||
@@ -129,12 +112,10 @@ const normalizeWebsiteUrl = raw => {
|
||||
};
|
||||
|
||||
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
|
||||
// onboarding_step and persist incomplete data. Also guard against
|
||||
// re-entry while a submit is in flight (double-click/Enter), which would
|
||||
// fire parallel requests that can duplicate the auto-created inbox/portal.
|
||||
if (isEnriching.value || isSubmitting.value) return;
|
||||
// Guard against re-entry while a submit is in flight (double-click/Enter),
|
||||
// which would fire parallel requests that can duplicate the auto-created
|
||||
// inbox/portal.
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
@@ -149,12 +130,8 @@ const handleSubmit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture which enrichable fields the user edited *before* normalizing the
|
||||
// website, so an untouched auto-filled domain isn't falsely flagged.
|
||||
const fieldsChanged = getChangedFields();
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
// Persist with a scheme so downstream portal consumers get a fully-qualified
|
||||
// URL regardless of what the user typed.
|
||||
website.value = normalizeWebsiteUrl(website.value);
|
||||
|
||||
isSubmitting.value = true;
|
||||
@@ -172,10 +149,6 @@ const handleSubmit = async () => {
|
||||
});
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: fieldsChanged,
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
@@ -211,7 +184,6 @@ const handleSubmit = async () => {
|
||||
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
|
||||
:is-loading="isSubmitting"
|
||||
:disabled="isEnriching"
|
||||
>
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
|
||||
@@ -253,113 +225,94 @@ const handleSubmit = async () => {
|
||||
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
|
||||
icon="i-lucide-briefcase-business"
|
||||
>
|
||||
<div
|
||||
v-if="isEnriching"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ accountName }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<img
|
||||
v-if="companyLogo"
|
||||
:src="companyLogo"
|
||||
:alt="accountName"
|
||||
class="size-4 object-contain"
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
|
||||
icon="i-lucide-globe"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<InlineInput
|
||||
ref="websiteInput"
|
||||
v-model="website"
|
||||
:readonly="!isEditingWebsite"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
|
||||
:custom-input-class="[
|
||||
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
|
||||
{ 'animate-shake': showErrorOnFields && v$.website.$error },
|
||||
]"
|
||||
@enter-press="handleWebsiteEnter"
|
||||
@blur="isEditingWebsite = false"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
icon="i-lucide-pencil"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="enableWebsiteEditing"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ accountName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
|
||||
icon="i-lucide-globe"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<InlineInput
|
||||
ref="websiteInput"
|
||||
v-model="website"
|
||||
:readonly="!isEditingWebsite"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
|
||||
:custom-input-class="[
|
||||
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
|
||||
{ 'animate-shake': showErrorOnFields && v$.website.$error },
|
||||
]"
|
||||
@enter-press="handleWebsiteEnter"
|
||||
@blur="isEditingWebsite = false"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
icon="i-lucide-pencil"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="enableWebsiteEditing"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
|
||||
icon="i-lucide-languages"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="locale"
|
||||
:has-error="showErrorOnFields && v$.locale.$error"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
|
||||
icon="i-lucide-clock"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="timezone"
|
||||
:has-error="showErrorOnFields && v$.timezone.$error"
|
||||
:options="timezoneOptions"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
|
||||
icon="i-lucide-factory"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="industry"
|
||||
:has-error="showErrorOnFields && v$.industry.$error"
|
||||
:options="INDUSTRY_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
|
||||
icon="i-lucide-users"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="companySize"
|
||||
:has-error="showErrorOnFields && v$.companySize.$error"
|
||||
:options="COMPANY_SIZE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
|
||||
icon="i-lucide-megaphone"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="referralSource"
|
||||
:has-error="showErrorOnFields && v$.referralSource.$error"
|
||||
:options="REFERRAL_SOURCE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</template>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
|
||||
icon="i-lucide-languages"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="locale"
|
||||
:has-error="showErrorOnFields && v$.locale.$error"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
|
||||
icon="i-lucide-clock"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="timezone"
|
||||
:has-error="showErrorOnFields && v$.timezone.$error"
|
||||
:options="timezoneOptions"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
|
||||
icon="i-lucide-factory"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="industry"
|
||||
:has-error="showErrorOnFields && v$.industry.$error"
|
||||
:options="INDUSTRY_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
|
||||
icon="i-lucide-users"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="companySize"
|
||||
:has-error="showErrorOnFields && v$.companySize.$error"
|
||||
:options="COMPANY_SIZE_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
|
||||
icon="i-lucide-megaphone"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="referralSource"
|
||||
:has-error="showErrorOnFields && v$.referralSource.$error"
|
||||
:options="REFERRAL_SOURCE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
</form>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
|
||||
const ENRICHMENT_TIMEOUT = 30000;
|
||||
|
||||
// Manages the post-signup enrichment lifecycle for the account-details form.
|
||||
// After signup the account is enriched asynchronously (onboarding_step ===
|
||||
// 'enrichment'); this fills empty form fields from the enriched
|
||||
// custom_attributes/brand_info as it arrives — idempotently, so it never
|
||||
// clobbers a value the user has already typed — waits the step out (with a
|
||||
// timeout fallback), and tracks which enrichable fields the user edited.
|
||||
//
|
||||
// `fields` is the set of form refs to populate, owned by the component so it can
|
||||
// still wire them to validation and the template.
|
||||
export function useAccountEnrichment(fields) {
|
||||
const { currentAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales: exact match
|
||||
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
|
||||
// locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of the auto-populated values, used to detect user edits at submit.
|
||||
const initialValues = ref({});
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const {
|
||||
website,
|
||||
timezone,
|
||||
company_size: companySize,
|
||||
industry,
|
||||
referral_source: referralSource,
|
||||
brand_info: brandInfo,
|
||||
} = currentAccount.value?.custom_attributes || {};
|
||||
|
||||
const fillIfEmpty = (field, value) => {
|
||||
if (!field.value) field.value = value || '';
|
||||
};
|
||||
|
||||
fillIfEmpty(fields.locale, detectBestLocale());
|
||||
fillIfEmpty(fields.website, website || brandInfo?.domain);
|
||||
fillIfEmpty(
|
||||
fields.timezone,
|
||||
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
fillIfEmpty(fields.companySize, companySize);
|
||||
fillIfEmpty(
|
||||
fields.industry,
|
||||
industry || brandInfo?.industries?.[0]?.industry
|
||||
);
|
||||
fillIfEmpty(fields.referralSource, referralSource);
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, ENRICHMENT_TIMEOUT);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, enriching => {
|
||||
if (enriching) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
// Enrichable fields the user actually edited since they were auto-filled.
|
||||
// Compare against the snapshot *before* the caller normalizes any values —
|
||||
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
|
||||
// compares unequal and gets falsely reported as changed.
|
||||
const getChangedFields = () => {
|
||||
const init = initialValues.value;
|
||||
const current = {
|
||||
website: fields.website.value,
|
||||
company_size: fields.companySize.value,
|
||||
industry: fields.industry.value,
|
||||
};
|
||||
return Object.entries(current)
|
||||
.filter(([key, value]) => value !== init[key])
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
return { isEnriching, getChangedFields };
|
||||
}
|
||||
@@ -112,38 +112,6 @@ export const CHANNEL_LIST = [
|
||||
const channelByType = type =>
|
||||
CHANNEL_LIST.find(channel => channel.type === type);
|
||||
|
||||
// Icons shown next to "View all" when every detected channel is already
|
||||
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
|
||||
// aren't duplicated.
|
||||
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
|
||||
channelByType
|
||||
);
|
||||
|
||||
// Social channels that detected brand_info socials map to, keyed by social type
|
||||
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
|
||||
// identity (label, channel_type) has a single source. Keys mirror
|
||||
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
|
||||
const SOCIAL_PLATFORM_TYPES = [
|
||||
'whatsapp',
|
||||
'facebook',
|
||||
'line',
|
||||
'instagram',
|
||||
'telegram',
|
||||
'tiktok',
|
||||
];
|
||||
|
||||
export const SOCIAL_PLATFORMS = Object.fromEntries(
|
||||
SOCIAL_PLATFORM_TYPES.map(type => {
|
||||
const { labelKey, inbox } = channelByType(type);
|
||||
return [type, { labelKey, channelType: inbox.channel_type }];
|
||||
})
|
||||
);
|
||||
|
||||
// Mailbox providers inferred from the signup domain's MX records, keyed by
|
||||
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
|
||||
export const EMAIL_PROVIDERS = Object.fromEntries(
|
||||
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
|
||||
channel.inbox.provider,
|
||||
{ labelKey: channel.labelKey },
|
||||
])
|
||||
);
|
||||
|
||||
@@ -1,94 +1,40 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import {
|
||||
SOCIAL_PLATFORMS,
|
||||
EMAIL_PROVIDERS,
|
||||
DEFAULT_CHANNEL_TYPES,
|
||||
} from './constants';
|
||||
import { CHANNEL_LIST, DEFAULT_CHANNEL_TYPES } from './constants';
|
||||
import { findConnectedInbox } from './channelMatchers';
|
||||
import { useChannelConfig } from './useChannelConfig';
|
||||
|
||||
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
|
||||
// is config-gated like everything else, then sliced to this limit.
|
||||
// How many channel rows to show. The list is installation-configured, then
|
||||
// sliced so the onboarding step stays compact on small screens.
|
||||
const DISPLAYED_CHANNEL_LIMIT = 3;
|
||||
const ONBOARDING_CHANNEL_TYPES = [
|
||||
'whatsapp',
|
||||
'facebook',
|
||||
'instagram',
|
||||
'telegram',
|
||||
'line',
|
||||
'tiktok',
|
||||
];
|
||||
|
||||
// Pull the handle/username out of a detected social URL, formatted per channel.
|
||||
const extractHandle = ({ type, url }) => {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
const path = pathname.replace(/^\/+|\/+$/g, '');
|
||||
if (type === 'whatsapp') {
|
||||
const digits = path.replace(/\D/g, '');
|
||||
return digits ? `+${digits}` : '';
|
||||
}
|
||||
if (type === 'line') return path;
|
||||
return path.startsWith('@') ? path : `@${path}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const channelByType = type =>
|
||||
CHANNEL_LIST.find(channel => channel.type === type);
|
||||
|
||||
// Derives the channel rows for the inbox-setup step from the account's detected
|
||||
// brand_info (socials + mailbox provider) and the real connected inboxes,
|
||||
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
|
||||
export function useDetectedChannels() {
|
||||
const { currentAccount } = useAccount();
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
const brandSocials = computed(
|
||||
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
|
||||
);
|
||||
|
||||
const connectedChannels = computed(() =>
|
||||
brandSocials.value
|
||||
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
|
||||
.map(social => ({
|
||||
type: social.type,
|
||||
handle: extractHandle(social),
|
||||
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
|
||||
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
|
||||
}))
|
||||
);
|
||||
|
||||
const detectedEmailChannel = computed(() => {
|
||||
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
|
||||
const provider = brandInfo?.email_provider;
|
||||
if (!EMAIL_PROVIDERS[provider]) return null;
|
||||
|
||||
return {
|
||||
type: 'email',
|
||||
handle: brandInfo?.email || '',
|
||||
labelKey: EMAIL_PROVIDERS[provider].labelKey,
|
||||
inbox: { channel_type: 'Channel::Email', provider },
|
||||
};
|
||||
});
|
||||
|
||||
// The real inbox backing a channel, if one exists — returned (not just a
|
||||
// boolean) so the row can show the connected account's real name.
|
||||
const connectedInbox = channel =>
|
||||
findConnectedInbox(inboxes.value, channel.inbox);
|
||||
|
||||
// A channel row built from a social type, with no detected handle — used for
|
||||
// the default suggestions when nothing was detected.
|
||||
const toChannelRow = type => ({
|
||||
const toChannelRow = type => {
|
||||
const channel = channelByType(type);
|
||||
return {
|
||||
type,
|
||||
handle: '',
|
||||
labelKey: SOCIAL_PLATFORMS[type].labelKey,
|
||||
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
|
||||
});
|
||||
labelKey: channel.labelKey,
|
||||
inbox: channel.inbox,
|
||||
};
|
||||
};
|
||||
|
||||
const detectedChannels = computed(() =>
|
||||
[detectedEmailChannel.value, ...connectedChannels.value]
|
||||
.filter(Boolean)
|
||||
// Email channels (including Gmail/Outlook OAuth) are disabled for this
|
||||
// phase; they will be enabled in a future PR.
|
||||
.filter(channel => channel.type !== 'email')
|
||||
// Hide channels whose installation OAuth credentials are missing — their
|
||||
// connect flow would only error.
|
||||
.filter(channel => isConfigured(channel.type))
|
||||
);
|
||||
// Onboarding intentionally offers explicit channel suggestions only. It no
|
||||
// longer derives social or mailbox channels from signup email-domain probes or
|
||||
// automatic remote enrichment.
|
||||
export function useDetectedChannels() {
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const { isConfigured } = useChannelConfig();
|
||||
|
||||
const defaultChannels = computed(() =>
|
||||
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
|
||||
@@ -96,30 +42,27 @@ export function useDetectedChannels() {
|
||||
.map(toChannelRow)
|
||||
);
|
||||
|
||||
// Show the detected channels, or fall back to the default suggestions so the
|
||||
// step is never an empty list.
|
||||
const displayedChannels = computed(() =>
|
||||
detectedChannels.value.length
|
||||
? detectedChannels.value
|
||||
: defaultChannels.value
|
||||
);
|
||||
const displayedChannels = defaultChannels;
|
||||
|
||||
const remainingChannels = computed(() => {
|
||||
// Exclude whatever is already shown as a row (detected or defaulted) so the
|
||||
// footer preview doesn't duplicate it.
|
||||
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
|
||||
return Object.entries(SOCIAL_PLATFORMS)
|
||||
.filter(([type]) => !shownTypes.has(type))
|
||||
.filter(([type]) => isConfigured(type))
|
||||
return ONBOARDING_CHANNEL_TYPES.filter(type => !shownTypes.has(type))
|
||||
.filter(isConfigured)
|
||||
.slice(0, 3)
|
||||
.map(([type, { labelKey, channelType }]) => ({
|
||||
type,
|
||||
labelKey,
|
||||
inbox: { channel_type: channelType },
|
||||
}));
|
||||
.map(type => {
|
||||
const channel = channelByType(type);
|
||||
return {
|
||||
type,
|
||||
labelKey: channel.labelKey,
|
||||
inbox: channel.inbox,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
|
||||
const connectedInbox = channel =>
|
||||
findConnectedInbox(inboxes.value, channel.inbox);
|
||||
|
||||
const hasDetectedChannels = computed(() => false);
|
||||
|
||||
return {
|
||||
displayedChannels,
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
const ENABLED_LANGUAGES = [
|
||||
{ iso_639_1_code: 'en', name: 'English' },
|
||||
{ iso_639_1_code: 'fr', name: 'French' },
|
||||
];
|
||||
|
||||
// Mounts the composable against a real store and the real useAccount/useConfig
|
||||
// (only useRoute and the underlying account getter / window config are faked),
|
||||
// so a change to how those resolve their data is exercised here too. `presets`
|
||||
// seeds form fields as if the user had already typed them.
|
||||
const mountComposable = ({
|
||||
account = {},
|
||||
enabledLanguages = ENABLED_LANGUAGES,
|
||||
presets = {},
|
||||
} = {}) => {
|
||||
window.chatwootConfig = { enabledLanguages };
|
||||
|
||||
const store = createStore({
|
||||
modules: {
|
||||
accounts: {
|
||||
namespaced: true,
|
||||
getters: { getAccount: () => () => account },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fields = {
|
||||
locale: ref(presets.locale || ''),
|
||||
website: ref(presets.website || ''),
|
||||
timezone: ref(presets.timezone || ''),
|
||||
companySize: ref(presets.companySize || ''),
|
||||
industry: ref(presets.industry || ''),
|
||||
referralSource: ref(presets.referralSource || ''),
|
||||
};
|
||||
|
||||
let api;
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
api = useAccountEnrichment(fields);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Component, { global: { plugins: [store] } });
|
||||
return { ...api, fields, wrapper };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootConfig;
|
||||
});
|
||||
|
||||
describe('useAccountEnrichment', () => {
|
||||
describe('populateFormFields', () => {
|
||||
it('fills empty fields from the enriched attributes on mount', () => {
|
||||
const { fields } = mountComposable({
|
||||
account: {
|
||||
locale: 'en',
|
||||
custom_attributes: {
|
||||
website: 'https://acme.com',
|
||||
timezone: 'America/New_York',
|
||||
company_size: '11-50',
|
||||
industry: 'Technology',
|
||||
referral_source: 'google',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('https://acme.com');
|
||||
expect(fields.timezone.value).toBe('America/New_York');
|
||||
expect(fields.companySize.value).toBe('11-50');
|
||||
expect(fields.industry.value).toBe('Technology');
|
||||
expect(fields.referralSource.value).toBe('google');
|
||||
});
|
||||
|
||||
it('falls back to brand_info for website and industry', () => {
|
||||
const { fields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
brand_info: {
|
||||
domain: 'acme.com',
|
||||
industries: [{ industry: 'Retail & E-commerce' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('acme.com');
|
||||
expect(fields.industry.value).toBe('Retail & E-commerce');
|
||||
});
|
||||
|
||||
it('does not clobber fields the user already set', () => {
|
||||
const { fields } = mountComposable({
|
||||
presets: { website: 'mysite.com', industry: 'Finance' },
|
||||
account: {
|
||||
custom_attributes: {
|
||||
website: 'https://enriched.com',
|
||||
industry: 'Technology',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fields.website.value).toBe('mysite.com');
|
||||
expect(fields.industry.value).toBe('Finance');
|
||||
});
|
||||
|
||||
it('detects the locale from the browser, else the account locale', () => {
|
||||
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
|
||||
const { fields } = mountComposable({ account: { locale: 'de' } });
|
||||
expect(fields.locale.value).toBe('en');
|
||||
|
||||
// No enabled language matches the browser -> fall back to account locale.
|
||||
const { fields: other } = mountComposable({
|
||||
account: { locale: 'de' },
|
||||
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
|
||||
});
|
||||
expect(other.locale.value).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnriching', () => {
|
||||
it('is true while the account is on the enrichment step', () => {
|
||||
const { isEnriching } = mountComposable({
|
||||
account: { custom_attributes: { onboarding_step: 'enrichment' } },
|
||||
});
|
||||
expect(isEnriching.value).toBe(true);
|
||||
});
|
||||
|
||||
it('is false on any other step', () => {
|
||||
const { isEnriching } = mountComposable({
|
||||
account: { custom_attributes: { onboarding_step: 'account_details' } },
|
||||
});
|
||||
expect(isEnriching.value).toBe(false);
|
||||
});
|
||||
|
||||
it('times out after 30s, flipping to false and populating', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { isEnriching, fields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
onboarding_step: 'enrichment',
|
||||
company_size: '51-200',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(isEnriching.value).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
expect(isEnriching.value).toBe(false);
|
||||
expect(fields.companySize.value).toBe('51-200');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangedFields', () => {
|
||||
it('lists only enrichable fields edited after auto-fill', () => {
|
||||
const { fields, getChangedFields } = mountComposable({
|
||||
account: {
|
||||
custom_attributes: {
|
||||
website: 'https://acme.com',
|
||||
company_size: '11-50',
|
||||
industry: 'Technology',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getChangedFields()).toEqual([]);
|
||||
|
||||
fields.industry.value = 'Finance';
|
||||
expect(getChangedFields()).toEqual(['industry']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,10 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
|
||||
|
||||
vi.mock('vue-router');
|
||||
|
||||
// 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,
|
||||
customAttributes = {},
|
||||
features = { channel_instagram: true },
|
||||
inboxes = [],
|
||||
isOnChatwootCloud = false,
|
||||
disableMetaInboxCreation = false,
|
||||
} = {}) => {
|
||||
const store = createStore({
|
||||
modules: {
|
||||
@@ -24,9 +17,8 @@ const mountComposable = ({
|
||||
namespaced: true,
|
||||
getters: {
|
||||
get: () => ({}),
|
||||
isOnChatwootCloud: () => isOnChatwootCloud,
|
||||
isMetaInboxCreationDisabled: () =>
|
||||
isOnChatwootCloud && disableMetaInboxCreation,
|
||||
isOnChatwootCloud: () => false,
|
||||
isMetaInboxCreationDisabled: () => false,
|
||||
isMetaMessageSendingDisabled: () => false,
|
||||
},
|
||||
},
|
||||
@@ -36,7 +28,7 @@ const mountComposable = ({
|
||||
getAccount: () => () => ({
|
||||
id: 1,
|
||||
features,
|
||||
custom_attributes: { brand_info: brandInfo },
|
||||
custom_attributes: customAttributes,
|
||||
}),
|
||||
isFeatureEnabledonAccount: () => (_accountId, feature) =>
|
||||
Boolean(features[feature]),
|
||||
@@ -62,8 +54,6 @@ const mountComposable = ({
|
||||
|
||||
beforeEach(() => {
|
||||
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||
// Configure the installation OAuth credentials so detected channels aren't
|
||||
// hidden by the config gate; individual tests clear this to assert hiding.
|
||||
window.chatwootConfig = {
|
||||
fbAppId: 'fb',
|
||||
instagramAppId: 'ig',
|
||||
@@ -78,282 +68,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useDetectedChannels', () => {
|
||||
describe('displayedChannels', () => {
|
||||
it('maps detected socials with a url to channel rows', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'whatsapp',
|
||||
handle: '+14155552671',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
handle: '@acme',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Instagram' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips socials without a url or with an unknown type', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'telegram' }, // no url
|
||||
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the raw path for line and falls back to empty on a bad url', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'line', url: 'https://line.me/acme' },
|
||||
{ type: 'facebook', url: 'not-a-url' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'line',
|
||||
handle: 'acme',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||
inbox: { channel_type: 'Channel::Line' },
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the detected email channel while email is disabled for this phase', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
it('does not derive channel rows from persisted remote enrichment data', () => {
|
||||
const { displayedChannels, hasDetectedChannels } = mountComposable({
|
||||
customAttributes: {
|
||||
brand_info: {
|
||||
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||
email_provider: 'google',
|
||||
email: 'support@acme.com',
|
||||
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'whatsapp',
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
it('falls back to the default channel suggestions when nothing is detected', () => {
|
||||
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||
|
||||
// The configured mainstream channels, with no detected handle.
|
||||
expect(displayedChannels.value).toEqual([
|
||||
{
|
||||
type: 'whatsapp',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
handle: '',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Instagram' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
|
||||
window.chatwootConfig = {}; // no OAuth credentials configured
|
||||
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||
|
||||
// Only the credential-free defaults survive (Telegram, LINE).
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'telegram',
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides detected channels whose installation OAuth credentials are missing', () => {
|
||||
window.chatwootConfig = {}; // nothing configured
|
||||
const { displayedChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'facebook', url: 'https://facebook.com/acme' },
|
||||
{ type: 'line', url: 'https://line.me/acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Meta channels on Chatwoot Cloud during the Meta restriction', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
features: {
|
||||
channel_instagram: true,
|
||||
whatsapp_embedded_signup_inbox_creation: true,
|
||||
},
|
||||
isOnChatwootCloud: true,
|
||||
disableMetaInboxCreation: true,
|
||||
brandInfo: {
|
||||
socials: [
|
||||
{ type: 'whatsapp', url: 'https://wa.me/14155552671' },
|
||||
{ type: 'facebook', url: 'https://facebook.com/acme' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Instagram when disabled for the account', () => {
|
||||
const { displayedChannels } = mountComposable({
|
||||
features: { channel_instagram: false },
|
||||
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',
|
||||
]);
|
||||
});
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'whatsapp',
|
||||
'facebook',
|
||||
'instagram',
|
||||
]);
|
||||
expect(displayedChannels.value.every(channel => !channel.handle)).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasDetectedChannels.value).toBe(false);
|
||||
});
|
||||
|
||||
describe('remainingChannels', () => {
|
||||
it('returns the platforms not already shown as default rows', () => {
|
||||
// Nothing detected → displayed falls back to the defaults (WhatsApp,
|
||||
// Facebook, Instagram), so the footer previews the remaining platforms.
|
||||
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||
it('keeps credential-free suggestions available on an unconfigured install', () => {
|
||||
window.chatwootConfig = {};
|
||||
const { displayedChannels } = mountComposable();
|
||||
|
||||
expect(remainingChannels.value).toEqual([
|
||||
{
|
||||
type: 'line',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||
inbox: { channel_type: 'Channel::Line' },
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
|
||||
inbox: { channel_type: 'Channel::Telegram' },
|
||||
},
|
||||
{
|
||||
type: 'tiktok',
|
||||
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
|
||||
inbox: { channel_type: 'Channel::Tiktok' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes already-detected socials, preserving order', () => {
|
||||
const { remainingChannels } = mountComposable({
|
||||
brandInfo: {
|
||||
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
|
||||
'facebook',
|
||||
'line',
|
||||
'instagram',
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes channels whose installation OAuth credentials are missing', () => {
|
||||
window.chatwootConfig = {}; // nothing configured
|
||||
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||
|
||||
// The only configured channels (Telegram, LINE) are shown as default rows,
|
||||
// and every other platform is gated out — so nothing remains for the footer.
|
||||
expect(remainingChannels.value).toEqual([]);
|
||||
});
|
||||
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||
'telegram',
|
||||
'line',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('connectedInbox', () => {
|
||||
it('returns the real inbox sharing the channel type', () => {
|
||||
const inbox = {
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
name: 'WA Biz',
|
||||
};
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [inbox],
|
||||
});
|
||||
it('returns remaining configured channel suggestions in stable order', () => {
|
||||
const { remainingChannels } = mountComposable();
|
||||
|
||||
expect(
|
||||
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
|
||||
).toBe(inbox);
|
||||
});
|
||||
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
|
||||
'telegram',
|
||||
'line',
|
||||
'tiktok',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches email inboxes on provider', () => {
|
||||
const gmail = {
|
||||
id: 1,
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'google',
|
||||
};
|
||||
const outlook = {
|
||||
id: 2,
|
||||
channel_type: 'Channel::Email',
|
||||
provider: 'microsoft',
|
||||
};
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [outlook, gmail],
|
||||
});
|
||||
it('finds an existing inbox by channel identity', () => {
|
||||
const inbox = {
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
name: 'WA Biz',
|
||||
};
|
||||
const { connectedInbox } = mountComposable({ inboxes: [inbox] });
|
||||
|
||||
expect(
|
||||
connectedInbox({
|
||||
inbox: { channel_type: 'Channel::Email', provider: 'google' },
|
||||
})
|
||||
).toBe(gmail);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
const { connectedInbox } = mountComposable({
|
||||
brandInfo: {},
|
||||
inboxes: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
|
||||
).toBeUndefined();
|
||||
});
|
||||
expect(
|
||||
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
|
||||
).toBe(inbox);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
|
||||
const ONBOARDING_STEPS = ['account_details', 'inbox_setup'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
const onboardingPath = step =>
|
||||
|
||||
@@ -104,5 +104,33 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the account has a removed enrichment step', () => {
|
||||
it('does not keep the account in onboarding', async () => {
|
||||
store.getters.getCurrentUser = {
|
||||
account_id: 1,
|
||||
id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'administrator',
|
||||
permissions: ['administrator'],
|
||||
status: 'active',
|
||||
onboarding_step: 'enrichment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const to = {
|
||||
name: 'onboarding_account_details',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user