feat: onboarding account details with enriched data [UPM-17][UPM-18] (#13979)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
co-authored by
Sivin Varghese
parent
51eb626b88
commit
224556fd1b
@@ -59,7 +59,6 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -107,8 +106,9 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
this.getAccount(this.currentAccountId);
|
||||
account;
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
v-if="!authUIFlags.isFetching"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -82,6 +86,7 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,6 +111,7 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +195,10 @@ 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 });
|
||||
|
||||
@@ -39,6 +39,7 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
@@ -83,5 +84,6 @@ export default {
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ONBOARDING_NEXT": {
|
||||
"GREETING": "Hello {name}!",
|
||||
"SUBTITLE": "Please review the following details",
|
||||
"YOUR_DETAILS": "Your details",
|
||||
"COMPANY_DETAILS": "Company details",
|
||||
"FIELDS": {
|
||||
"EMAIL": "Email",
|
||||
"YOUR_ROLE": "Your Role",
|
||||
"WEBSITE": "Website",
|
||||
"LANGUAGE": "Language",
|
||||
"TIMEZONE": "Timezone",
|
||||
"COMPANY_SIZE": "Company Size",
|
||||
"INDUSTRY": "Industry",
|
||||
"REFERRAL_SOURCE": "Where did you find us?"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"SELECT_ROLE": "Select your role",
|
||||
"ENTER_WEBSITE": "www.example.com",
|
||||
"SELECT_LANGUAGE": "Select language",
|
||||
"SELECT_TIMEZONE": "Select timezone",
|
||||
"SELECT_COMPANY_SIZE": "Select company size",
|
||||
"SELECT_INDUSTRY": "Select industry",
|
||||
"SELECT_REFERRAL_SOURCE": "Select source"
|
||||
},
|
||||
"EMAIL_VERIFIED": "Email verified",
|
||||
"SETTING_UP": "Setting up your account...",
|
||||
"CONTINUE": "Continue",
|
||||
"SAVING": "Saving...",
|
||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||
"SUCCESS": "Details saved successfully",
|
||||
"ERROR": "Could not save details. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
|
||||
import AppContainer from './Dashboard.vue';
|
||||
import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -31,6 +32,14 @@ export default {
|
||||
...campaignsRoutes.routes,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/onboarding'),
|
||||
name: 'onboarding_account_details',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
component: OnboardingAccountDetails,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
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,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const userRole = ref('');
|
||||
const website = ref('');
|
||||
const locale = ref('');
|
||||
const timezone = ref('');
|
||||
const companySize = ref('');
|
||||
const industry = ref('');
|
||||
const referralSource = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isEditingWebsite = ref(false);
|
||||
const websiteInput = ref(null);
|
||||
const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
industry: {},
|
||||
referralSource: {},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, {
|
||||
userRole,
|
||||
website,
|
||||
locale,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
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 || [])];
|
||||
return langs
|
||||
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
|
||||
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
|
||||
});
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').map(tz => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to 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 auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
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()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
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.
|
||||
if (isEnriching.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
|
||||
showErrorOnFields.value = true;
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
domain: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
referral_source: referralSource.value,
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
|
||||
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
|
||||
:is-loading="isSubmitting"
|
||||
:disabled="isEnriching"
|
||||
>
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<Avatar :name="userName" :size="16" rounded-full />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ userName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
|
||||
icon="i-lucide-mail"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
|
||||
<Icon
|
||||
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="userRole"
|
||||
:has-error="showErrorOnFields && v$.userRole.$error"
|
||||
:options="USER_ROLE_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
: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') }}
|
||||
</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"
|
||||
/>
|
||||
<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>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||
<span class="text-n-slate-11">{{ title }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
hasError: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end">
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
|
||||
:class="[
|
||||
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
|
||||
{ 'animate-shake': hasError },
|
||||
]"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-if="placeholder" value="" disabled>
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
greeting: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
continueLabel: { type: String, default: 'Continue' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['continue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
|
||||
>
|
||||
<!-- Grid background with corner fade -->
|
||||
<div
|
||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||
/>
|
||||
<div class="relative w-full max-w-[580px]">
|
||||
<div class="relative ps-12">
|
||||
<!-- Timeline dotted line -->
|
||||
<svg
|
||||
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
|
||||
width="1"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="97%"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="mb-6 -ms-12 flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
|
||||
>
|
||||
<slot name="greeting-icon">
|
||||
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ greeting }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="text-sm text-n-slate-11">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections -->
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Continue button with curved connector -->
|
||||
<div class="relative ps-12 overflow-visible">
|
||||
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
|
||||
<svg
|
||||
width="48"
|
||||
height="40"
|
||||
viewBox="0 0 47 40"
|
||||
fill="none"
|
||||
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="15"
|
||||
y1="0"
|
||||
x2="48"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
|
||||
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
|
||||
stroke="url(#line-gradient)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
|
||||
<svg
|
||||
width="6"
|
||||
height="6"
|
||||
viewBox="0 0 6 6"
|
||||
fill="none"
|
||||
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
|
||||
>
|
||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||
</svg>
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-center"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<!-- Section header with icon + triangles -->
|
||||
<div class="flex items-center gap-4 mb-3 -ms-12">
|
||||
<div class="flex flex-col items-center z-10 flex-shrink-0">
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
|
||||
>
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-11" />
|
||||
</div>
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
export const COMPANY_SIZE_OPTIONS = [
|
||||
{ value: '1-10', label: '1 - 10' },
|
||||
{ value: '11-50', label: '11 - 50' },
|
||||
{ value: '51-200', label: '51 - 200' },
|
||||
{ value: '201-500', label: '201 - 500' },
|
||||
{ value: '500+', label: '500+' },
|
||||
];
|
||||
|
||||
export const INDUSTRY_OPTIONS = [
|
||||
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
|
||||
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
|
||||
{
|
||||
value: 'Automotive & Transportation',
|
||||
label: 'Automotive & Transportation',
|
||||
},
|
||||
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
|
||||
{
|
||||
value: 'Construction & Built Environment',
|
||||
label: 'Construction & Built Environment',
|
||||
},
|
||||
{
|
||||
value: 'Consumer Packaged Goods (CPG)',
|
||||
label: 'Consumer Packaged Goods (CPG)',
|
||||
},
|
||||
{ value: 'Education', label: 'Education' },
|
||||
{ value: 'Entertainment', label: 'Entertainment' },
|
||||
{ value: 'Finance', label: 'Finance' },
|
||||
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
|
||||
{ value: 'Healthcare', label: 'Healthcare' },
|
||||
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
|
||||
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
|
||||
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
|
||||
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
|
||||
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
|
||||
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
|
||||
{ value: 'News & Media', label: 'News & Media' },
|
||||
{
|
||||
value: 'Professional Services & Agencies',
|
||||
label: 'Professional Services & Agencies',
|
||||
},
|
||||
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
|
||||
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
|
||||
{ value: 'Sports', label: 'Sports' },
|
||||
{ value: 'Technology', label: 'Technology' },
|
||||
{ value: 'Telecommunications', label: 'Telecommunications' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const REFERRAL_SOURCE_OPTIONS = [
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'reddit', label: 'Reddit' },
|
||||
{ value: 'twitter', label: 'Twitter/X' },
|
||||
{ value: 'linkedin', label: 'LinkedIn' },
|
||||
{ value: 'friend', label: 'Friend/Colleague' },
|
||||
{ value: 'blog', label: 'Blog/Article' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const USER_ROLE_OPTIONS = [
|
||||
{ value: 'founder', label: 'Founder/CEO' },
|
||||
{ value: 'product_manager', label: 'Product Manager' },
|
||||
{ value: 'engineering', label: 'Engineering' },
|
||||
{ value: 'support_lead', label: 'Support Lead' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
|
||||
import dashboard from './dashboard/dashboard.routes';
|
||||
import store from 'dashboard/store';
|
||||
import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
const { isLoggedIn, getCurrentUser: user } = store.getters;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
@@ -27,8 +29,25 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
return next(frontendURL('no-accounts'));
|
||||
}
|
||||
|
||||
const routeAccountId = Number(to.params?.accountId || accountId);
|
||||
const userAccount = accounts.find(a => a.id === routeAccountId);
|
||||
const isAdmin = userAccount?.role === 'administrator';
|
||||
const isActive = userAccount?.status === 'active';
|
||||
const needsOnboarding =
|
||||
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
|
||||
isAdmin &&
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
||||
const target = needsOnboarding ? 'onboarding' : 'dashboard';
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
if (needsOnboarding && !isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
|
||||
}
|
||||
if (!needsOnboarding && isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
|
||||
}
|
||||
|
||||
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
|
||||
@@ -38,15 +57,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const initalizeRouter = () => {
|
||||
const userAuthentication = store.dispatch('setUser');
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
AnalyticsHelper.page(to.name || '', {
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
});
|
||||
|
||||
userAuthentication.then(() => {
|
||||
return validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
await userAuthentication;
|
||||
await validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
|
||||
id: null,
|
||||
accounts: [],
|
||||
},
|
||||
'accounts/getAccount': () => ({}),
|
||||
},
|
||||
dispatch: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
});
|
||||
|
||||
describe('when route is not accessible to current user', () => {
|
||||
it('should redirect to dashboard', () => {
|
||||
it('should redirect to dashboard', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
|
||||
});
|
||||
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should go to the intended route', () => {
|
||||
it('should go to the intended route', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -54,18 +54,19 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
get: async ({ commit }, { silent } = {}) => {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
}
|
||||
try {
|
||||
const response = await AccountAPI.get();
|
||||
commit(types.default.ADD_ACCOUNT, response.data);
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch {
|
||||
// silent failure
|
||||
} finally {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
update: async ({ commit }, { options, ...updateObj }) => {
|
||||
|
||||
@@ -269,6 +269,20 @@ export const mutations = {
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.RESET_ONBOARDING](_state, accountId) {
|
||||
const accounts = _state.currentUser.accounts.map(account => {
|
||||
if (account.id === accountId) {
|
||||
const { onboarding_step, ...rest } = account;
|
||||
return rest;
|
||||
}
|
||||
return account;
|
||||
});
|
||||
|
||||
_state.currentUser = {
|
||||
..._state.currentUser,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.CLEAR_USER](_state) {
|
||||
_state.currentUser = initialState.currentUser;
|
||||
},
|
||||
|
||||
@@ -57,4 +57,46 @@ describe('#mutations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('#RESET_ONBOARDING', () => {
|
||||
it('removes onboarding_step from the targeted account', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
onboarding_step: 'account_details',
|
||||
role: 'administrator',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 1);
|
||||
expect(state.currentUser.accounts[0]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
expect(state.currentUser.accounts[0].role).toEqual('administrator');
|
||||
});
|
||||
|
||||
it('targets the route account, not currentUser.account_id', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{ id: 1, onboarding_step: 'account_details' },
|
||||
{ id: 2, onboarding_step: 'account_details' },
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 2);
|
||||
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
|
||||
'account_details'
|
||||
);
|
||||
expect(state.currentUser.accounts[1]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
SET_CURRENT_USER: 'SET_CURRENT_USER',
|
||||
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
|
||||
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
|
||||
RESET_ONBOARDING: 'RESET_ONBOARDING',
|
||||
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
|
||||
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user