diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb
index 181e4965e..d7c49b35d 100644
--- a/app/controllers/api/v1/accounts/onboardings_controller.rb
+++ b/app/controllers/api/v1/accounts/onboardings_controller.rb
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
+ ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
+ STEP_ACCOUNT_DETAILS = 'account_details'.freeze
+ STEP_INBOX_SETUP = 'inbox_setup'.freeze
+ ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
+
def update
+ return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
+
@account = Current.account
- finalize = finalizing_account_details?
-
- @account.assign_attributes(account_params)
- @account.custom_attributes.merge!(custom_attributes_params)
- @account.custom_attributes.delete('onboarding_step') if finalize
- @account.save!
-
- # TODO: re-enable when the help center generation UI is ready to surface progress
- # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
+ # The client declares the step it is completing; `account_details` runs
+ # `complete_account_details`, and so on. The known-step guard above keeps the
+ # client value from `send`-ing an arbitrary method.
+ send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
- def finalizing_account_details?
- @account.custom_attributes['onboarding_step'] == 'account_details'
+ def complete_account_details
+ # Only act while the cursor still points here, so a stale replay after
+ # onboarding finished can't re-enter it.
+ return unless current_step == STEP_ACCOUNT_DETAILS
+
+ @account.assign_attributes(account_params)
+ @account.custom_attributes.merge!(custom_attributes_params)
+
+ # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
+ # environment check); self-hosted finishes onboarding here.
+ if ChatwootApp.chatwoot_cloud?
+ move_to_step(STEP_INBOX_SETUP)
+ create_onboarding_inboxes
+ else
+ finish_onboarding
+ end
end
- def website
- custom_attributes_params[:website]
+ def complete_inbox_setup
+ # Only finalize while the cursor still points here, so a stale or out-of-order
+ # request can't end onboarding early. Replays are no-ops.
+ return unless current_step == STEP_INBOX_SETUP
+
+ finish_onboarding
+ end
+
+ def current_step
+ @account.custom_attributes[ONBOARDING_STEP_KEY]
+ end
+
+ def move_to_step(step)
+ @account.custom_attributes[ONBOARDING_STEP_KEY] = step
+ @account.save!
+ end
+
+ def finish_onboarding
+ @account.custom_attributes.delete(ONBOARDING_STEP_KEY)
+ @account.save!
+ end
+
+ def create_onboarding_inboxes
+ Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
index 58d2821ef..9e1b932b0 100644
--- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js
+++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
+ INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
+ INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
+ INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js
index f47df9e5b..4100cfb32 100644
--- a/app/javascript/dashboard/helper/inbox.js
+++ b/app/javascript/dashboard/helper/inbox.js
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
+// Short channel-type slugs used to identify a channel without leaning on its
+// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
+export const CHANNEL_TYPES = {
+ WEBSITE: 'website',
+ WHATSAPP: 'whatsapp',
+ FACEBOOK: 'facebook',
+ INSTAGRAM: 'instagram',
+ TIKTOK: 'tiktok',
+ TELEGRAM: 'telegram',
+ LINE: 'line',
+ GMAIL: 'gmail',
+ OUTLOOK: 'outlook',
+ SMS: 'sms',
+ API: 'api',
+ VOICE: 'voice',
+ EMAIL: 'email',
+};
+
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
diff --git a/app/javascript/dashboard/i18n/locale/en/onboarding.json b/app/javascript/dashboard/i18n/locale/en/onboarding.json
index d7c960002..51d511091 100644
--- a/app/javascript/dashboard/i18n/locale/en/onboarding.json
+++ b/app/javascript/dashboard/i18n/locale/en/onboarding.json
@@ -30,5 +30,59 @@
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
+ },
+ "ONBOARDING_INBOX_SETUP": {
+ "GREETING": "Let's set up a few things",
+ "SUBTITLE": "This will give you head-start in your workspace",
+ "CONTINUE": "Continue",
+ "SKIP": "Skip",
+ "ERROR": "Something went wrong. Please try again.",
+ "WHATSAPP_CONNECTED": "WhatsApp connected successfully",
+ "FACEBOOK_CONNECTED": "Facebook connected successfully",
+ "CREATED_FOR_YOU": {
+ "TITLE": "We've created the following for you",
+ "LIVE_CHAT": "Live Chat widget",
+ "LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
+ "LIVE_CHAT_STATUS": "Almost done…",
+ "LIVE_CHAT_READY": "Ready",
+ "HELP_CENTER": "Help Center",
+ "HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
+ "HELP_CENTER_GENERATING": "Creating your help center…",
+ "HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
+ "HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
+ "HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
+ "HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
+ "HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
+ "HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
+ },
+ "CHANNELS": {
+ "TITLE": "Connect all your conversation channels",
+ "HEADER": "We found a few channels you can connect",
+ "CONNECT": "Connect",
+ "CONNECTED": "Connected",
+ "MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
+ "MORE_CHANNELS_EMAIL": "Email",
+ "MORE_CHANNELS_VOICE": "Voice",
+ "VIEW_ALL": "View all",
+ "GMAIL": "Gmail",
+ "OUTLOOK": "Outlook",
+ "OTHER_EMAIL": "Other Email Providers"
+ },
+ "CHANNELS_DIALOG": {
+ "TITLE": "Connect all your channels instantly",
+ "SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
+ "NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
+ "CONNECT_TITLE": "Connect your {name} account",
+ "CONNECT_SUBTITLE": "Fill out these quick details",
+ "CONNECT": "Connect",
+ "BACK": "Back",
+ "SETUP_LATER": "Setup later in app",
+ "FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
+ "FACEBOOK_LAUNCH": "Continue with Facebook",
+ "FACEBOOK_SELECT_PAGE": "Select a Page to connect",
+ "FACEBOOK_LOADING": "Loading your Facebook Pages…",
+ "FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
+ "FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
+ }
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
index 87bae7d11..04d11c621 100644
--- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
+import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
export default {
routes: [
@@ -40,6 +41,14 @@ export default {
},
component: OnboardingAccountDetails,
},
+ {
+ path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
+ name: 'onboarding_inbox_setup',
+ meta: {
+ permissions: ['administrator', 'agent', 'custom_role'],
+ },
+ component: OnboardingInboxSetup,
+ },
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue b/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue
new file mode 100644
index 000000000..34dda961e
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/InboxSetup.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
index 3a1d65bc6..7e4fbc84e 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
@@ -1,21 +1,22 @@
+
+
+
+
+
+
+
+ {{ t(channel.labelKey) }}
+
+
+ {{ connectedName }}
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue
new file mode 100644
index 000000000..c0157398e
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/CreationStatusRow.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
{{ status }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue
new file mode 100644
index 000000000..8c0e1decc
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/HelpCenterCreationStatus.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue
new file mode 100644
index 000000000..45f170137
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelForm.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue
new file mode 100644
index 000000000..a3631649d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsDialog.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue
new file mode 100644
index 000000000..b5c138c65
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxChannelsFooter.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue
new file mode 100644
index 000000000..48be07821
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/InboxFacebookForm.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
+
+
+
+
+
+
+
+
+ {{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue
new file mode 100644
index 000000000..aeb40e24c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/WebWidgetCreationStatus.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js
new file mode 100644
index 000000000..0ebdd728f
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/channelMatchers.js
@@ -0,0 +1,18 @@
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+
+// A detected channel maps to a real inbox when they share a channel_type. Gmail
+// and Outlook both use Channel::Email, so for email we also match on provider.
+// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
+
+// Returns the matching inbox (not a boolean) so callers can show the connected
+// account's real name rather than the detected handle.
+export const findConnectedInbox = (inboxes, stub) =>
+ inboxes.find(
+ inbox =>
+ inbox.channel_type === stub?.channel_type &&
+ (stub?.channel_type !== INBOX_TYPES.EMAIL ||
+ inbox.provider === stub?.provider)
+ );
+
+export const isChannelConnected = (inboxes, stub) =>
+ Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js
new file mode 100644
index 000000000..91e800d06
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/constants.js
@@ -0,0 +1,149 @@
+import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
+
+// Channels whose connect flow opens the channels dialog preselected to their
+// in-dialog step — Facebook (page picker) and the credential-form channels
+// (Telegram, Line) — rather than redirecting through OAuth.
+export const DIALOG_CHANNELS = [
+ CHANNEL_TYPES.FACEBOOK,
+ CHANNEL_TYPES.TELEGRAM,
+ CHANNEL_TYPES.LINE,
+];
+
+// Suggested channels (in priority order) to offer as rows when nothing is
+// detected, so the step isn't empty. The mainstream OAuth channels show on
+// configured installs, while credential-free Telegram/LINE keep the list
+// non-empty on a bare self-host.
+export const DEFAULT_CHANNEL_TYPES = [
+ CHANNEL_TYPES.WHATSAPP,
+ CHANNEL_TYPES.FACEBOOK,
+ CHANNEL_TYPES.INSTAGRAM,
+ CHANNEL_TYPES.TELEGRAM,
+ CHANNEL_TYPES.LINE,
+];
+
+// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
+// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
+// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
+// exists and falls back to the monochrome glyph otherwise, so no per-channel
+// style flag is needed. Entries without a channel type (Voice, Other Email
+// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
+// inline credential form; `setupLater: true` defers the channel to in-app setup
+// for this phase. `labelKey` is an i18n key — most reuse the shared channel
+// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
+// names translate without duplicating strings; resolve it with `t()` at display.
+export const CHANNEL_LIST = [
+ {
+ type: 'website',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
+ inbox: { channel_type: 'Channel::WebWidget' },
+ },
+ {
+ type: 'whatsapp',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
+ inbox: { channel_type: 'Channel::Whatsapp' },
+ },
+ {
+ type: 'instagram',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Instagram' },
+ },
+ {
+ type: 'facebook',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
+ inbox: { channel_type: 'Channel::FacebookPage' },
+ },
+ {
+ type: 'tiktok',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
+ inbox: { channel_type: 'Channel::Tiktok' },
+ },
+ {
+ type: 'telegram',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
+ inbox: { channel_type: 'Channel::Telegram' },
+ form: true,
+ },
+ {
+ type: 'line',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
+ inbox: { channel_type: 'Channel::Line' },
+ form: true,
+ },
+ // Email channels (including Gmail/Outlook OAuth) are set up later in-app for
+ // this phase; they will be enabled in a future PR.
+ {
+ type: 'gmail',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
+ inbox: { channel_type: 'Channel::Email', provider: 'google' },
+ setupLater: true,
+ },
+ {
+ type: 'outlook',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
+ inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
+ setupLater: true,
+ },
+ {
+ type: 'sms',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
+ inbox: { channel_type: 'Channel::Sms' },
+ setupLater: true,
+ },
+ {
+ type: 'api',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
+ inbox: { channel_type: 'Channel::Api' },
+ setupLater: true,
+ },
+ {
+ type: 'voice',
+ labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
+ fallbackIcon: 'i-woot-voice',
+ setupLater: true,
+ },
+ {
+ type: 'email',
+ labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
+ fallbackIcon: 'i-woot-mail',
+ setupLater: true,
+ },
+];
+
+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 },
+ ])
+);
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
new file mode 100644
index 000000000..ba2d6f0dc
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
@@ -0,0 +1,29 @@
+import { useMapGetter } from 'dashboard/composables/store';
+
+// OAuth/SDK channels need installation-level app credentials to be usable. When
+// the credential is missing the channel is "not configured" and is hidden from
+// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
+// need no installation credential and are always considered configured.
+// Mirrors the availability checks in ChannelItem.vue.
+export function useChannelConfig() {
+ const globalConfig = useMapGetter('globalConfig/get');
+ const installationConfig = window.chatwootConfig || {};
+
+ const CHANNEL_CONFIGURED = {
+ // WhatsApp is onboarded only via Meta embedded signup, which needs both the
+ // app id (not the 'none' sentinel) and the signup configuration id.
+ whatsapp: () =>
+ Boolean(installationConfig.whatsappAppId) &&
+ installationConfig.whatsappAppId !== 'none' &&
+ Boolean(installationConfig.whatsappConfigurationId),
+ facebook: () => Boolean(installationConfig.fbAppId),
+ instagram: () => Boolean(installationConfig.instagramAppId),
+ tiktok: () => Boolean(installationConfig.tiktokAppId),
+ gmail: () => Boolean(installationConfig.googleOAuthClientId),
+ outlook: () => Boolean(globalConfig.value.azureAppId),
+ };
+
+ const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
+
+ return { isConfigured };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
new file mode 100644
index 000000000..bd34d5f20
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConnect.js
@@ -0,0 +1,67 @@
+import { useI18n } from 'vue-i18n';
+import { useAlert } from 'dashboard/composables';
+import { useStore } from 'dashboard/composables/store';
+import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
+import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
+import googleClient from 'dashboard/api/channel/googleClient';
+import microsoftClient from 'dashboard/api/channel/microsoftClient';
+import instagramClient from 'dashboard/api/channel/instagramClient';
+import tiktokClient from 'dashboard/api/channel/tiktokClient';
+
+// Channels that complete via an OAuth redirect. Email channels are keyed by their
+// Channel::Email provider, others by channel type. The request is tagged with a
+// return hint so the callback brings the user back to onboarding instead of the
+// inbox settings page.
+const OAUTH_CLIENTS = {
+ google: googleClient,
+ microsoft: microsoftClient,
+ instagram: instagramClient,
+ tiktok: tiktokClient,
+};
+
+export function useChannelConnect() {
+ const { t } = useI18n();
+ const store = useStore();
+ const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
+
+ const connectViaOAuth = async provider => {
+ const client = OAUTH_CLIENTS[provider];
+ if (!client) return;
+
+ try {
+ const {
+ data: { url },
+ } = await client.generateAuthorization({ return_to: 'onboarding' });
+ window.location.href = url;
+ } catch {
+ useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
+ }
+ };
+
+ // WhatsApp connects via Meta's embedded-signup popup instead of the redirect
+ // OAuth flow above. Collect the signup credentials, exchange them for an
+ // inbox, and surface the result inline — then refetch so the connected state
+ // reflects the freshly created inbox (and renders its real channel icon).
+ const connectWhatsapp = async () => {
+ let credentials;
+ try {
+ credentials = await runEmbeddedSignup();
+ } catch {
+ useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
+ return;
+ }
+ if (!credentials) return; // user dismissed the popup
+
+ try {
+ await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
+ await store.dispatch('inboxes/get');
+ useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
+ } catch (error) {
+ useAlert(
+ parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
+ );
+ }
+ };
+
+ return { connectViaOAuth, connectWhatsapp };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js
new file mode 100644
index 000000000..6ba68c6bf
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useDetectedChannels.js
@@ -0,0 +1,130 @@
+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 { 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.
+const DISPLAYED_CHANNEL_LIMIT = 3;
+
+// 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 '';
+ }
+};
+
+// 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 => ({
+ type,
+ handle: '',
+ labelKey: SOCIAL_PLATFORMS[type].labelKey,
+ inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
+ });
+
+ 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))
+ );
+
+ const defaultChannels = computed(() =>
+ DEFAULT_CHANNEL_TYPES.filter(isConfigured)
+ .slice(0, DISPLAYED_CHANNEL_LIMIT)
+ .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 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))
+ .slice(0, 3)
+ .map(([type, { labelKey, channelType }]) => ({
+ type,
+ labelKey,
+ inbox: { channel_type: channelType },
+ }));
+ });
+
+ const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
+
+ return {
+ displayedChannels,
+ remainingChannels,
+ connectedInbox,
+ hasDetectedChannels,
+ };
+}
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
similarity index 81%
rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
index 63b3fa391..90abff58f 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingLayout.vue
@@ -5,11 +5,12 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
+ skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
-defineEmits(['continue']);
+defineEmits(['continue', 'skip']);
@@ -20,7 +21,7 @@ defineEmits(['continue']);
-
+
-
- {{ continueLabel }}
-
+
+
+ {{ continueLabel }}
+
+
+ {{ skipLabel }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
similarity index 87%
rename from app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
index 479aa4c81..976e5fa9d 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/shared/OnboardingSection.vue
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
+ bare: { type: Boolean, default: false },
});
@@ -42,8 +43,12 @@ defineProps({
-
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js
similarity index 100%
rename from app/javascript/dashboard/routes/dashboard/onboarding/constants.js
rename to app/javascript/dashboard/routes/dashboard/onboarding/shared/constants.js
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js
new file mode 100644
index 000000000..a8d85010a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/account-details/useAccountEnrichment.spec.js
@@ -0,0 +1,186 @@
+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']);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js
new file mode 100644
index 000000000..68dca058a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/HelpCenterCreationStatus.spec.js
@@ -0,0 +1,123 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
+import OnboardingAPI from 'dashboard/api/onboarding';
+
+vi.mock('dashboard/api/onboarding', () => ({
+ default: {
+ getHelpCenterGeneration: vi.fn(),
+ },
+}));
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key, params = {}) => {
+ if (key.endsWith('HELP_CENTER_CATEGORIES')) {
+ return `${params.count} categories`;
+ }
+ if (key.endsWith('HELP_CENTER_SUMMARY')) {
+ return `${params.count} articles across ${params.categories}`;
+ }
+ if (key.endsWith('HELP_CENTER_ARTICLES')) {
+ return `${params.count} articles`;
+ }
+ return key;
+ },
+ }),
+}));
+
+const mountStatus = () =>
+ mount(HelpCenterCreationStatus, {
+ global: {
+ stubs: {
+ CreationStatusRow: {
+ props: ['ready', 'title', 'description', 'status'],
+ template:
+ '
{{ status }}
',
+ },
+ },
+ },
+ });
+
+describe('HelpCenterCreationStatus', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ });
+
+ it('renders completed summary from the status endpoint', async () => {
+ OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'completed' },
+ articles_count: 3,
+ categories_count: 2,
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
+ 'true'
+ );
+ expect(wrapper.find('[data-test="row"]').text()).toBe(
+ '3 articles across 2 categories'
+ );
+ });
+
+ it('hides the row when generation is skipped', async () => {
+ OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'skipped' },
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
+ });
+
+ it('polls while generating and stops after completion', async () => {
+ vi.useFakeTimers();
+ OnboardingAPI.getHelpCenterGeneration
+ .mockResolvedValueOnce({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'generating' },
+ articles_count: 1,
+ categories_count: 0,
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ generation_id: 'generation-123',
+ state: { status: 'completed' },
+ articles_count: 2,
+ categories_count: 1,
+ },
+ });
+
+ const wrapper = mountStatus();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
+
+ vi.advanceTimersByTime(5000);
+ await flushPromises();
+
+ expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
+ expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
+ 'true'
+ );
+ expect(wrapper.find('[data-test="row"]').text()).toBe(
+ '2 articles across 1 categories'
+ );
+
+ vi.advanceTimersByTime(5000);
+ await flushPromises();
+
+ expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js
new file mode 100644
index 000000000..bf150f95d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxChannelsDialog.spec.js
@@ -0,0 +1,59 @@
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
+vi.mock('dashboard/composables/store', () => ({
+ useMapGetter: () => ({ value: {} }),
+}));
+vi.mock('../../inbox-setup/useChannelConnect', () => ({
+ useChannelConnect: () => ({
+ connectViaOAuth: vi.fn(),
+ connectWhatsapp: vi.fn(),
+ }),
+}));
+
+const mountDialog = () =>
+ mount(InboxChannelsDialog, {
+ props: { inboxes: [] },
+ global: {
+ stubs: {
+ Dialog: {
+ template: '
',
+ methods: { open() {}, close() {} },
+ },
+ InboxFacebookForm: { template: '' },
+ InboxChannelForm: { template: '' },
+ ChannelIcon: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('InboxChannelsDialog Facebook gating', () => {
+ afterEach(() => {
+ delete window.chatwootConfig;
+ });
+
+ it('opens the Facebook page picker when fbAppId is configured', async () => {
+ window.chatwootConfig = { fbAppId: 'fb-app' };
+ const wrapper = mountDialog();
+
+ wrapper.vm.open('facebook');
+ await nextTick();
+
+ expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
+ });
+
+ it('shows the grid (not the picker) when fbAppId is missing', async () => {
+ window.chatwootConfig = {};
+ const wrapper = mountDialog();
+
+ wrapper.vm.open('facebook');
+ await nextTick();
+
+ expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
+ // The channel grid renders its cards instead.
+ expect(wrapper.find('button').exists()).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js
new file mode 100644
index 000000000..888082f68
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/InboxFacebookForm.spec.js
@@ -0,0 +1,158 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { ref, nextTick } from 'vue';
+import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
+import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
+vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
+vi.mock('dashboard/store/utils/api', () => ({
+ parseAPIErrorResponse: vi.fn(),
+}));
+vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
+ useFacebookPageConnect: vi.fn(),
+}));
+
+const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
+vi.mock('dashboard/composables/store', () => ({
+ useStore: () => ({ dispatch }),
+}));
+
+const NextButtonStub = {
+ props: ['label', 'disabled', 'isLoading'],
+ emits: ['click'],
+ template: ``,
+};
+const ComboBoxStub = {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue'],
+ template: '',
+};
+
+const PAGES = [
+ { id: 'p1', name: 'Page One', access_token: 'pt1' },
+ { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
+];
+
+const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
+const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
+
+let loginAndFetchPages;
+let preloadSdk;
+
+const mountForm = () =>
+ mount(InboxFacebookForm, {
+ global: {
+ stubs: {
+ NextButton: NextButtonStub,
+ ComboBox: ComboBoxStub,
+ Spinner: true,
+ },
+ },
+ });
+
+const clickButton = (wrapper, label) =>
+ wrapper
+ .findAll('button')
+ .find(button => button.text() === label)
+ .trigger('click');
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ preloadSdk = vi.fn();
+ loginAndFetchPages = vi.fn();
+ useFacebookPageConnect.mockReturnValue({
+ isAuthenticating: ref(false),
+ preloadSdk,
+ loginAndFetchPages,
+ });
+ dispatch.mockResolvedValue({ id: 1 });
+});
+
+describe('InboxFacebookForm', () => {
+ it('preloads the SDK on mount', () => {
+ mountForm();
+ expect(preloadSdk).toHaveBeenCalled();
+ });
+
+ it('lists only connectable pages and creates an inbox for the selected one', async () => {
+ loginAndFetchPages.mockResolvedValue({
+ userAccessToken: 'tok',
+ pages: PAGES,
+ });
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ // p2 is already connected (exists), so only p1 is offered.
+ const combobox = wrapper.findComponent(ComboBoxStub);
+ expect(combobox.props('options')).toEqual([
+ { value: 'p1', label: 'Page One' },
+ ]);
+
+ combobox.vm.$emit('update:modelValue', 'p1');
+ await nextTick();
+
+ await clickButton(wrapper, CONNECT);
+ await flushPromises();
+
+ expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
+ user_access_token: 'tok',
+ page_access_token: 'pt1',
+ page_id: 'p1',
+ inbox_name: 'Page One',
+ });
+ expect(wrapper.emitted('created')).toBeTruthy();
+ });
+
+ it('shows the empty state when every page is already connected', async () => {
+ loginAndFetchPages.mockResolvedValue({
+ userAccessToken: 'tok',
+ pages: [
+ { id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
+ ],
+ });
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
+ );
+ expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
+ });
+
+ it('shows an error when the connection fails', async () => {
+ loginAndFetchPages.mockRejectedValue(new Error('boom'));
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it('stays on the connect prompt without an error when cancelled', async () => {
+ loginAndFetchPages.mockResolvedValue(null);
+ const wrapper = mountForm();
+
+ await clickButton(wrapper, LAUNCH);
+ await flushPromises();
+ await nextTick();
+
+ expect(wrapper.text()).not.toContain(
+ 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
+ );
+ // Launch button is still available to retry.
+ expect(
+ wrapper.findAll('button').some(button => button.text() === LAUNCH)
+ ).toBe(true);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js
new file mode 100644
index 000000000..acde1159d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/channelMatchers.spec.js
@@ -0,0 +1,62 @@
+import {
+ findConnectedInbox,
+ isChannelConnected,
+} from '../../inbox-setup/channelMatchers';
+
+const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
+const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
+const OUTLOOK = {
+ id: 3,
+ channel_type: 'Channel::Email',
+ provider: 'microsoft',
+};
+
+describe('channelMatchers', () => {
+ describe('findConnectedInbox', () => {
+ it('returns the inbox sharing the channel type', () => {
+ expect(
+ findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
+ ).toBe(WHATSAPP);
+ });
+
+ it('matches email inboxes on provider', () => {
+ expect(
+ findConnectedInbox([OUTLOOK, GMAIL], {
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ })
+ ).toBe(GMAIL);
+ });
+
+ it('does not match a different email provider', () => {
+ expect(
+ findConnectedInbox([OUTLOOK], {
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ })
+ ).toBeUndefined();
+ });
+
+ it('returns undefined when nothing matches', () => {
+ expect(
+ findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
+ ).toBeUndefined();
+ });
+ });
+
+ describe('isChannelConnected', () => {
+ it('is true when a matching inbox exists', () => {
+ expect(
+ isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
+ ).toBe(true);
+ });
+
+ it('is false when no inbox matches', () => {
+ expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
+ });
+
+ it('is false for a channel without an inbox stub', () => {
+ expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
new file mode 100644
index 000000000..1134d4494
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
@@ -0,0 +1,300 @@
+import { defineComponent, h } from 'vue';
+import { createStore } from 'vuex';
+import { mount } from '@vue/test-utils';
+import { useRoute } from 'vue-router';
+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, inboxes = [] } = {}) => {
+ const store = createStore({
+ modules: {
+ accounts: {
+ namespaced: true,
+ getters: {
+ getAccount: () => () => ({
+ id: 1,
+ custom_attributes: { brand_info: brandInfo },
+ }),
+ },
+ },
+ inboxes: {
+ namespaced: true,
+ getters: { getInboxes: () => inboxes },
+ },
+ },
+ });
+
+ let result;
+ const Component = defineComponent({
+ setup() {
+ result = useDetectedChannels();
+ return () => h('div');
+ },
+ });
+ mount(Component, { global: { plugins: [store] } });
+ return result;
+};
+
+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',
+ tiktokAppId: 'tt',
+ whatsappAppId: 'wa',
+ whatsappConfigurationId: 'wa-config',
+ };
+});
+
+afterEach(() => {
+ delete window.chatwootConfig;
+});
+
+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: {
+ 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',
+ ]);
+ });
+ });
+
+ 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: {} });
+
+ 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([]);
+ });
+ });
+
+ 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],
+ });
+
+ expect(
+ connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
+ ).toBe(inbox);
+ });
+
+ 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],
+ });
+
+ 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();
+ });
+ });
+});
diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js
index 3fd2aa0e0..c82029801 100644
--- a/app/javascript/dashboard/routes/index.js
+++ b/app/javascript/dashboard/routes/index.js
@@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
-const ONBOARDING_STEPS = ['account_details', 'enrichment'];
+const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
const routes = [...dashboard.routes];
+const onboardingPath = step =>
+ step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
+
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = async (to, next) => {
@@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
- const target = needsOnboarding ? 'onboarding' : 'dashboard';
+ const target = needsOnboarding
+ ? onboardingPath(userAccount?.onboarding_step)
+ : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
- return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
+ return next(
+ frontendURL(
+ `accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
+ )
+ );
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
index 1311bc3fc..1b2639d39 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
@@ -6,6 +6,27 @@ module Enterprise::Api::V1::Accounts::OnboardingsController
private
+ def create_onboarding_inboxes
+ super
+ create_help_center
+ end
+
+ def complete_inbox_setup
+ # Drop the onboarding-only generation pointer; the OSS method's save! persists both deletions.
+ @account.custom_attributes.delete('help_center_generation_id')
+ super
+ end
+
+ def create_help_center
+ return if website.blank?
+
+ Onboarding::HelpCenterCreationService.new(@account, Current.user).perform
+ end
+
+ def website
+ custom_attributes_params[:website]
+ end
+
def help_center_generation_status
generation_id = help_center_generation_id
return super if generation_id.blank?
diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
index b5f7eb247..fb231f85f 100644
--- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
+++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
@@ -20,6 +20,16 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
skip_generation(generation_id: generation_id, reason: e.message)
+ rescue Firecrawl::FirecrawlError
+ # Must propagate untouched: retry_on handles it, and recording a skipped
+ # state here would make the retries no-op via the state guard above.
+ raise
+ rescue StandardError => e
+ # Any other failure is terminal (missing LLM config, code bug). Record a
+ # skipped state so the onboarding status row stops polling instead of
+ # showing "generating" forever, then re-raise for error tracking.
+ skip_generation(generation_id: generation_id, reason: "#{e.class}: #{e.message}")
+ raise
end
private
diff --git a/lib/tasks/onboarding.rake b/lib/tasks/onboarding.rake
deleted file mode 100644
index d61a77cc3..000000000
--- a/lib/tasks/onboarding.rake
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace :onboarding do
- desc 'Reset onboarding for an account (triggers the onboarding flow again). Usage: rake onboarding:reset[account_id]'
- task :reset, [:account_id] => :environment do |_task, args|
- abort 'Error: Please provide an account ID' if args[:account_id].blank?
-
- account = Account.find_by(id: args[:account_id])
- abort "Error: Account with ID '#{args[:account_id]}' not found" unless account
-
- account.custom_attributes['onboarding_step'] = 'account_details'
- account.save!
-
- puts "Onboarding has been reset for account '#{account.name}' (ID: #{account.id})"
- end
-end
diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
index 6f118624b..6c2b48805 100644
--- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe 'Onboarding API', type: :request do
it 'saves name and locale' do
patch "/api/v1/accounts/#{account.id}/onboarding",
- params: { name: 'Acme Inc', locale: 'fr' },
+ params: { name: 'Acme Inc', locale: 'fr', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
@@ -50,7 +50,7 @@ RSpec.describe 'Onboarding API', type: :request do
it 'merges custom_attributes' do
patch "/api/v1/accounts/#{account.id}/onboarding",
- params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
+ params: { website: 'acme.com', industry: 'tech', company_size: '10-50', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
attrs = account.reload.custom_attributes
@@ -59,47 +59,121 @@ RSpec.describe 'Onboarding API', type: :request do
expect(attrs['company_size']).to eq('10-50')
end
+ context 'when on cloud (inbox setup is a cloud-only step)' do
+ before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) }
+
+ it 'advances onboarding_step to inbox_setup' do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'acme.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup')
+ end
+
+ it 'does not create a help center portal when website is blank' do
+ expect do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { name: 'Acme Inc', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+ end.not_to change(account.portals, :count)
+ end
+
+ it 'is idempotent when the account_details completion is replayed' do
+ 2.times do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'acme.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+ end
+
+ # Replaying step 1 always lands on inbox_setup; it never skips to done.
+ expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup')
+ end
+ end
+
+ context 'when off cloud (inbox setup is skipped)' do
+ before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) }
+
+ it 'finishes onboarding instead of advancing to inbox_setup' do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'acme.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
+ end
+
+ it 'does not auto-create onboarding inboxes' do
+ expect(Onboarding::WebWidgetCreationService).not_to receive(:new)
+
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'acme.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+ end
+ end
+ end
+
+ context 'when replaying account_details after onboarding has finished' do
+ before { account.update!(custom_attributes: { 'website' => 'acme.com' }) }
+
+ it 'does not re-enter onboarding or persist the stale payload' do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'stale.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
+ expect(account.custom_attributes['website']).to eq('acme.com')
+ end
+ end
+
+ context 'when finalizing inbox_setup' do
+ before { account.update!(custom_attributes: { 'onboarding_step' => 'inbox_setup' }) }
+
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
- params: { website: 'acme.com' },
+ params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
- it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
- service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
- allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
+ it 'does not create another web widget inbox' do
+ expect(Onboarding::WebWidgetCreationService).not_to receive(:new)
patch "/api/v1/accounts/#{account.id}/onboarding",
- params: { website: 'acme.com' },
+ params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
-
- expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
- expect(arg_account.id).to eq(account.id)
- expect(arg_user.id).to eq(admin.id)
- end
- expect(service).to have_received(:perform)
end
- it 'does not create a help center portal when website is blank' do
- expect do
+ it 'is idempotent when the finalize request is replayed' do
+ 2.times do
patch "/api/v1/accounts/#{account.id}/onboarding",
- params: { name: 'Acme Inc' },
+ params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
- end.not_to change(account.portals, :count)
+ end
+
+ expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
end
- context 'when onboarding_step is not account_details' do
+ context 'when the declared onboarding_step is missing or unknown' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
- it 'does not clear onboarding_step' do
+ it 'rejects a request without an onboarding_step and changes nothing' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
+ expect(account.custom_attributes['website']).to be_nil
+ end
+
+ it 'rejects an unknown onboarding_step' do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { onboarding_step: 'invite_team' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
end
it 'does not create a help center portal' do
@@ -110,6 +184,19 @@ RSpec.describe 'Onboarding API', type: :request do
end.not_to change(account.portals, :count)
end
end
+
+ context 'when completing inbox_setup out of order' do
+ before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
+
+ it 'does not clear onboarding_step while the account is still on account_details' do
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { onboarding_step: 'inbox_setup' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(account.reload.custom_attributes['onboarding_step']).to eq('account_details')
+ end
+ end
end
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
index 59d0564fa..5b7279eb3 100644
--- a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -4,6 +4,31 @@ RSpec.describe 'Enterprise Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
+ describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
+ context 'when finalizing account_details' do
+ # Inbox/help-center setup is a cloud-only step; off cloud the flow finishes at account_details.
+ before do
+ account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ end
+
+ it 'invokes HelpCenterCreationService when website is present' do
+ service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
+ allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
+
+ patch "/api/v1/accounts/#{account.id}/onboarding",
+ params: { website: 'acme.com', onboarding_step: 'account_details' },
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
+ expect(arg_account.id).to eq(account.id)
+ expect(arg_user.id).to eq(admin.id)
+ end
+ expect(service).to have_received(:perform)
+ end
+ end
+ end
+
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when help center generation is in progress' do
let(:generation_id) { 'generation-123' }